hecks 3.14.0 → 4.0.0
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.
- checksums.yaml +4 -4
- data/examples/pizzas/adapters/fake_processor.rb +34 -0
- data/examples/pizzas/adapters/payment_signature.rb +66 -0
- data/examples/pizzas/adapters/payment_webhook.rb +87 -0
- data/examples/pizzas/bluebook/pizzas.behaviors +33 -0
- data/examples/pizzas/bluebook/pizzas.hecksagon +5 -3
- data/examples/pizzas/config.ru +22 -0
- data/lib/hecks/adapters/driven/prism.rb +0 -2
- data/lib/hecks/adapters/driven.rb +0 -2
- data/lib/hecks/adapters/driving/json.rb +1 -7
- data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -2
- data/lib/hecks/bluebook/dsl/derivation_words.rb +58 -0
- data/lib/hecks/bluebook/dsl/translation_builder.rb +6 -37
- data/lib/hecks/bluebook/expression/ast_json.rb +0 -9
- data/lib/hecks/bluebook/hecksagon.rb +1 -1
- data/lib/hecks/bluebook/meta_validator/translation_judge/rules.rb +7 -10
- data/lib/hecks/bluebook/meta_validator/translation_judge.rb +4 -8
- data/lib/hecks/bluebook/meta_validator.rb +0 -11
- data/lib/hecks/bluebook/translation.rb +18 -10
- data/lib/hecks/bluebook/translation_expression/evaluator.rb +63 -0
- data/lib/hecks/bluebook/translation_expression/parser.rb +127 -0
- data/lib/hecks/bluebook/translation_expression.rb +39 -0
- data/lib/hecks/bluebook/translation_plan.rb +70 -0
- data/lib/hecks/bluebook.rb +2 -0
- data/lib/hecks/chapters.rb +3 -3
- data/lib/hecks/composer/adapters/claude_composer.adapter +3 -0
- data/lib/hecks/composer/adapters/claude_composer.rb +83 -0
- data/lib/hecks/composer/adapters/fake_agent.adapter +3 -0
- data/lib/hecks/composer/adapters/fake_agent.rb +55 -0
- data/lib/hecks/composer/adapters/jev.adapter +3 -0
- data/lib/hecks/composer/adapters/jev.rb +66 -0
- data/lib/hecks/composer/agent.rb +113 -0
- data/lib/hecks/composer/assistant.rb +110 -0
- data/lib/hecks/composer/behavior_run.rb +53 -0
- data/lib/hecks/composer/bluebook/composer.bluebook +178 -0
- data/lib/hecks/composer/bluebook/composer.hecksagon +16 -0
- data/lib/hecks/composer/board.rb +65 -0
- data/lib/hecks/composer/board_edit/command_text.rb +39 -0
- data/lib/hecks/composer/board_edit/source.rb +84 -0
- data/lib/hecks/composer/board_edit.rb +144 -0
- data/lib/hecks/composer/check.rb +89 -0
- data/lib/hecks/composer/domains.rb +81 -0
- data/lib/hecks/composer/jobs.rb +134 -0
- data/lib/hecks/composer/layout.rb +37 -0
- data/lib/hecks/composer/live.rb +150 -0
- data/lib/hecks/composer/outline.rb +48 -0
- data/lib/hecks/composer/page.html +989 -0
- data/lib/hecks/composer/panels.rb +142 -0
- data/lib/hecks/composer/parse.rb +83 -0
- data/lib/hecks/composer/plain.rb +28 -0
- data/lib/hecks/composer/ports/composer_agent.port +7 -0
- data/lib/hecks/composer/session.rb +141 -0
- data/lib/hecks/composer/toolbox.rb +166 -0
- data/lib/hecks/composer/web.rb +156 -0
- data/lib/hecks/composer/workbench.rb +51 -0
- data/lib/hecks/composer/workspace.rb +81 -0
- data/lib/hecks/composer.rb +17 -0
- data/lib/hecks/corpus/adapters.rb +22 -0
- data/lib/hecks/corpus/rust.rb +1 -1
- data/lib/hecks/corpus/worlds.rb +19 -0
- data/lib/hecks/corpus.rb +5 -0
- data/lib/hecks/forms/app/commands.rb +2 -3
- data/lib/hecks/forms/app/queries.rb +2 -3
- data/lib/hecks/forms/app/records.rb +17 -5
- data/lib/hecks/forms/command_form_renderer.rb +42 -16
- data/lib/hecks/forms/field_controls.rb +2 -2
- data/lib/hecks/forms/field_renderer.rb +1 -1
- data/lib/hecks/forms/field_shape.rb +36 -17
- data/lib/hecks/forms/index_renderer.rb +2 -1
- data/lib/hecks/forms/page.rb +1 -1
- data/lib/hecks/forms/params.rb +0 -13
- data/lib/hecks/forms/query_form_renderer.rb +7 -6
- data/lib/hecks/forms/record_renderer.rb +11 -7
- data/lib/hecks/forms/rule_words.rb +100 -0
- data/lib/hecks/forms/value_object_shape.rb +2 -1
- data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +0 -3
- data/lib/hecks/fuzzing/isolated_boot/worlds.rb +3 -6
- data/lib/hecks/fuzzing/replay/filters.rb +2 -6
- data/lib/hecks/gate/stages.yml +25 -0
- data/lib/hecks/grammar/evolve/seeds.rb +0 -6
- data/lib/hecks/grammar/evolve.rb +0 -3
- data/lib/hecks/grammar/translation.bluebook +1 -1
- data/lib/hecks/hecks/adapters/codebase/composing.rb +56 -0
- data/lib/hecks/hecks/adapters/codebase/source_tree.rb +3 -1
- data/lib/hecks/hecks/adapters/journal_store/changes.rb +0 -26
- data/lib/hecks/hecks/adapters/journal_store/examination.rb +2 -23
- data/lib/hecks/hecks/adapters/journal_store/readings.rb +3 -11
- data/lib/hecks/hecks/adapters/journal_store.rb +0 -11
- data/lib/hecks/hecks/adapters/rehearsal/inquiry.rb +4 -11
- data/lib/hecks/hecks/adapters/rehearsal/memory_store.rb +0 -9
- data/lib/hecks/hecks/adapters/rehearsal/postgres_reading.rb +0 -4
- data/lib/hecks/hecks/codebase.behaviors +6 -0
- data/lib/hecks/hecks/codebase.bluebook +21 -0
- data/lib/hecks/hecks/composer.behaviors +68 -0
- data/lib/hecks/hecks/context_map.hecksagon +1 -1
- data/lib/hecks/hecks/custodian.behaviors +0 -6
- data/lib/hecks/hecks/custodian.bluebook +3 -80
- data/lib/hecks/hecks/hecks.hecksagon +2 -2
- data/lib/hecks/hecks/hecks.world +1 -1
- data/lib/hecks/hecks/rehearsal.bluebook +7 -13
- data/lib/hecks/kernel_capabilities/templates.rb +4 -4
- data/lib/hecks/kernel_capabilities.rb +1 -1
- data/lib/hecks/language/bluebook/vocabulary.bluebook +2 -3
- data/lib/hecks/language/translation/translation_aggregate.bluebook +21 -24
- data/lib/hecks/ports/access_control.rb +2 -2
- data/lib/hecks/ports/persistence/plugins/era/era_check.rb +3 -41
- data/lib/hecks/ports/persistence/plugins/era/lineage/coverage.rb +7 -7
- data/lib/hecks/ports/persistence/plugins/era/lineage/rewrite.rb +25 -2
- data/lib/hecks/ports/persistence/plugins/era/lineage/rules.rb +4 -4
- data/lib/hecks/ports/persistence/plugins/era/lineage.rb +17 -25
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/base_sql.rb +0 -12
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/domain_rename.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +2 -22
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +0 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage.rb +9 -3
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +3 -29
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/refusals.rb +2 -26
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +6 -33
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +3 -3
- data/lib/hecks/ports/persistence/plugins/era/translation/audit.rb +2 -4
- data/lib/hecks/ports/persistence/plugins/era/translation/expression_sql.rb +52 -0
- data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +23 -29
- data/lib/hecks/ports/persistence/plugins/era/translation/scaffold.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/translation.rb +0 -1
- data/lib/hecks/ports/persistence/plugins/era.rb +3 -6
- data/lib/hecks/ports.rb +0 -1
- data/lib/hecks/projection_files.rb +3 -1
- data/lib/hecks/projections/deploy/templates/lambda/nat_comment_oauth.tmpl +1 -1
- data/lib/hecks/projections/glossary/entries.rb +10 -4
- data/lib/hecks/projections/glossary/groups.rb +42 -0
- data/lib/hecks/projections/glossary/html/page.rb +7 -2
- data/lib/hecks/projections/glossary/html/parser.rb +1 -1
- data/lib/hecks/projections/glossary/html/sections.rb +31 -11
- data/lib/hecks/projections/glossary/html.rb +1 -2
- data/lib/hecks/projections/glossary/markdown.rb +31 -28
- data/lib/hecks/projections/glossary/mermaid.rb +3 -0
- data/lib/hecks/projections/glossary/page.css +143 -153
- data/lib/hecks/projections/glossary/page.js +63 -3
- data/lib/hecks/projections/glossary/sentences/actions.rb +88 -0
- data/lib/hecks/projections/glossary/sentences/paths.rb +77 -0
- data/lib/hecks/projections/glossary/sentences/queries.rb +90 -0
- data/lib/hecks/projections/glossary/sentences/value_objects.rb +32 -5
- data/lib/hecks/projections/glossary/sentences.rb +13 -3
- data/lib/hecks/projections/glossary/slugs.rb +4 -1
- data/lib/hecks/projections/glossary.rb +2 -1
- data/lib/hecks/projections/plain_english.rb +97 -0
- data/lib/hecks/projections/rust_vocabulary/arguments.rb +2 -2
- data/lib/hecks/projections/rust_vocabulary/enum_body.rb +20 -25
- data/lib/hecks/projections/rust_vocabulary/tests.rb +2 -2
- data/lib/hecks/projections/rust_vocabulary.rb +14 -6
- data/lib/hecks/projections/site/cms_editor/color.rb +0 -6
- data/lib/hecks/projections/statements/actions.rb +99 -0
- data/lib/hecks/projections/statements/relationships.rb +86 -0
- data/lib/hecks/projections/statements.rb +15 -24
- data/lib/hecks/projector/exporter/adapters.rb +112 -0
- data/lib/hecks/projector/exporter/translations.rb +18 -35
- data/lib/hecks/projector/exporter/worlds.rb +103 -0
- data/lib/hecks/projector/exporter.rb +5 -1
- data/lib/hecks/quality_control/adapters/agent.rb +6 -7
- data/lib/hecks/quality_control/adapters/agent_profile.rb +16 -114
- data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +1 -1
- data/lib/hecks/runtime/loader/boot_steps.rb +0 -20
- data/lib/hecks/runtime/outbox.rb +2 -9
- data/lib/hecks/runtime/port_operation_interpreter.rb +1 -9
- data/lib/hecks/runtime/remote_dispatcher.rb +2 -2
- data/lib/hecks/runtime/value.rb +13 -0
- data/lib/hecks/rust_build/project_rust/target_ir.rb +23 -4
- data/lib/hecks/rust_build/wasm.rb +0 -12
- data/lib/hecks/rust_build/write_if_changed.rb +12 -12
- data/lib/hecks/rust_format.rb +77 -0
- data/lib/hecks/tools/fuzz_sweep/findings.rb +3 -9
- data/lib/hecks/tools/fuzz_sweep.rb +1 -3
- data/lib/hecks/tools/translation_audit/verdicts.rb +3 -28
- data/lib/hecks/tools/translation_audit.rb +8 -10
- data/lib/hecks/tools/translation_scaffold/reporting.rb +1 -1
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +2 -1
- data/lib/hecks.rb +3 -0
- data/prebuilt/{a883a7e568667aa5ac904c82daff356bdf9e996252a36af6def4a4a43efb967e.marshal → 02c09c73b272c1fb5a9ae6d87bb5e5e529208e00f6b4b597dc2c0fd70a9c0964.marshal} +0 -0
- data/prebuilt/{1a6f596be124228be93eba1d86dbc9d2933190fa389b284331241cb9f27115a0.marshal → bb84a835cf6a15136bad5ca03aa5bf93a5b92f5f72444618d3ef2412b987fb32.marshal} +0 -0
- data/prebuilt/{verdicts-872993b1aa26d73fe48f28251374b8585fed145d02aab42be0f00de386b6ff80.json → verdicts-f1f10da14c8e8f01b4c9b7e3e2d74be8cd2bf4676364ed17fa16975d2a076d75.json} +1 -1
- data/rust/Cargo.toml +40 -0
- data/rust/build/Cargo.toml +36 -0
- data/rust/build/clippy.toml +4 -0
- data/rust/build/src/adapters_pass.rs +196 -0
- data/rust/build/src/binding_pass.rs +54 -13
- data/rust/build/src/build_artifact.rs +23 -5
- data/rust/build/src/cargo_sync.rs +69 -26
- data/rust/build/src/lineage_pass.rs +101 -87
- data/rust/build/src/main.rs +11 -2
- data/rust/build/src/pipeline.rs +80 -19
- data/rust/build/src/reserved_names.rs +6 -50
- data/rust/build/src/resolve.rs +82 -35
- data/rust/build/src/rustfmt.rs +100 -0
- data/rust/build/src/seams_pass.rs +85 -16
- data/rust/build/src/subprocess.rs +30 -6
- data/rust/build/src/tmp.rs +5 -2
- data/rust/build/src/translation_pass.rs +72 -22
- data/rust/build/src/world_pass.rs +109 -0
- data/rust/clippy.toml +4 -0
- data/rust/codegen/Cargo.toml +36 -0
- data/rust/codegen/clippy.toml +4 -0
- data/rust/codegen/src/asks.rs +40 -14
- data/rust/codegen/src/attr.rs +2 -2
- data/rust/codegen/src/bridging.rs +676 -117
- data/rust/codegen/src/commands.rs +979 -373
- data/rust/codegen/src/constraints.rs +88 -25
- data/rust/codegen/src/dependency_planning.rs +128 -34
- data/rust/codegen/src/domain_generator.rs +300 -170
- data/rust/codegen/src/exemplar.rs +87 -32
- data/rust/codegen/src/expr_emitter.rs +54 -6
- data/rust/codegen/src/fielded.rs +193 -41
- data/rust/codegen/src/hecks_naming.rs +124 -16
- data/rust/codegen/src/hecks_naming_tests.rs +81 -0
- data/rust/codegen/src/json.rs +80 -44
- data/rust/codegen/src/json_codec.rs +585 -149
- data/rust/codegen/src/literal.rs +104 -138
- data/rust/codegen/src/main.rs +145 -52
- data/rust/codegen/src/manifest.rs +76 -14
- data/rust/codegen/src/mutations.rs +1212 -239
- data/rust/codegen/src/naming.rs +111 -60
- data/rust/codegen/src/optional_pass.rs +90 -28
- data/rust/codegen/src/ports.rs +5 -4
- data/rust/codegen/src/prelude.rs +139 -49
- data/rust/codegen/src/queries.rs +601 -226
- data/rust/codegen/src/reactions.rs +268 -74
- data/rust/codegen/src/read_models.rs +606 -205
- data/rust/codegen/src/reference_specs.rs +16 -3
- data/rust/codegen/src/registry.rs +128 -79
- data/rust/codegen/src/reserved_names.rs +6 -50
- data/rust/codegen/src/shared.rs +71 -22
- data/rust/codegen/src/sidecars.rs +15 -5
- data/rust/codegen/src/skip_reason.rs +8 -2
- data/rust/codegen/src/types.rs +306 -108
- data/rust/host/Cargo.lock +1 -0
- data/rust/host/Cargo.toml +40 -4
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/clippy.toml +4 -0
- data/rust/host/src/api.rs +698 -218
- data/rust/host/src/auth.rs +491 -214
- data/rust/host/src/b64.rs +91 -0
- data/rust/host/src/bin/expr_harness.rs +6 -2
- data/rust/host/src/bin/lineage_harness.rs +47 -11
- data/rust/host/src/bin/mint_harness.rs +70 -37
- data/rust/host/src/bin/validate_harness.rs +16 -8
- data/rust/host/src/boot.rs +144 -68
- data/rust/host/src/boundary_fuzz/api.rs +130 -32
- data/rust/host/src/boundary_fuzz/auth.rs +108 -23
- data/rust/host/src/boundary_fuzz/expr_json.rs +85 -11
- data/rust/host/src/boundary_fuzz/ir.rs +164 -12
- data/rust/host/src/boundary_fuzz/server.rs +283 -36
- data/rust/host/src/boundary_fuzz/web.rs +56 -8
- data/rust/host/src/checkout.rs +78 -32
- data/rust/host/src/clock.rs +8 -0
- data/rust/host/src/commerce_ir.rs +333 -95
- data/rust/host/src/crypto.rs +33 -0
- data/rust/host/src/dispatch.rs +990 -331
- data/rust/host/src/expr_json.rs +1021 -273
- data/rust/host/src/extension.rs +24 -4
- data/rust/host/src/field_hints.rs +11 -7
- data/rust/host/src/fuzz_support.rs +85 -13
- data/rust/host/src/host.rs +100 -0
- data/rust/host/src/http.rs +22 -0
- data/rust/host/src/ir.rs +326 -16
- data/rust/host/src/journal.rs +277 -176
- data/rust/host/src/lambda_client.rs +219 -53
- data/rust/host/src/lib.rs +16 -4
- data/rust/host/src/log.rs +62 -11
- data/rust/host/src/mint.rs +1153 -326
- data/rust/host/src/needs.rs +185 -44
- data/rust/host/src/payments/keystore.rs +197 -45
- data/rust/host/src/payments/roles.rs +39 -7
- data/rust/host/src/payments/tests.rs +1427 -282
- data/rust/host/src/payments.rs +391 -112
- data/rust/host/src/platform_signin/attack_tests.rs +5 -5
- data/rust/host/src/platform_signin/config.rs +11 -11
- data/rust/host/src/platform_signin/cookie.rs +2 -2
- data/rust/host/src/platform_signin/extension.rs +2 -1
- data/rust/host/src/platform_signin/handler.rs +9 -8
- data/rust/host/src/platform_signin/jti.rs +4 -2
- data/rust/host/src/platform_signin/jwks.rs +2 -1
- data/rust/host/src/platform_signin/jws.rs +2 -37
- data/rust/host/src/platform_signin/known_answer_tests.rs +5 -1
- data/rust/host/src/platform_signin/mode_tests.rs +5 -3
- data/rust/host/src/platform_signin/pages.rs +20 -20
- data/rust/host/src/platform_signin/session.rs +8 -1
- data/rust/host/src/platform_signin/settings.rs +6 -6
- data/rust/host/src/platform_signin/strict_json.rs +12 -9
- data/rust/host/src/platform_signin/verify.rs +41 -41
- data/rust/host/src/platform_signin/wiring_support.rs +13 -18
- data/rust/host/src/presentation.rs +207 -63
- data/rust/host/src/presentation_write.rs +502 -187
- data/rust/host/src/query_step.rs +69 -18
- data/rust/host/src/rate_limit.rs +541 -142
- data/rust/host/src/reference_transform.rs +77 -25
- data/rust/host/src/reference_validate.rs +443 -104
- data/rust/host/src/resend.rs +190 -55
- data/rust/host/src/secrets.rs +116 -21
- data/rust/host/src/server.rs +483 -104
- data/rust/host/src/storage_shape.rs +88 -21
- data/rust/host/src/test_pg.rs +17 -7
- data/rust/host/src/ui_schema.rs +495 -207
- data/rust/host/src/wasm_runner.rs +21 -6
- data/rust/host/src/web/commerce.rs +101 -23
- data/rust/host/src/web/newsletter/cooldown.rs +10 -3
- data/rust/host/src/web/newsletter/template.rs +26 -7
- data/rust/host/src/web/newsletter/tests.rs +112 -23
- data/rust/host/src/web/newsletter.rs +209 -59
- data/rust/host/src/web/newsletter_send.rs +251 -79
- data/rust/host/src/web/registration_receipt/tests.rs +16 -4
- data/rust/host/src/web/registration_receipt.rs +100 -24
- data/rust/host/src/web/registrations.rs +1384 -332
- data/rust/host/src/web/resend_webhook.rs +138 -80
- data/rust/host/src/web.rs +2416 -508
- data/rust/lsp/Cargo.toml +36 -0
- data/rust/lsp/clippy.toml +4 -0
- data/rust/lsp/src/json.rs +29 -22
- data/rust/lsp/src/main.rs +81 -31
- data/rust/lsp/src/outline.rs +23 -7
- data/rust/lsp/src/rpc.rs +17 -4
- data/rust/parser/Cargo.toml +36 -0
- data/rust/parser/clippy.toml +4 -0
- data/rust/parser/src/build/closed_sets.rs +6 -1
- data/rust/parser/src/build/identity.rs +4 -3
- data/rust/parser/src/build/mod.rs +1 -1
- data/rust/parser/src/build/named_pattern.rs +4 -1
- data/rust/parser/src/build/naming.rs +2 -80
- data/rust/parser/src/build/pattern_subset.rs +6 -8
- data/rust/parser/src/build/query_derive.rs +3 -1
- data/rust/parser/src/build/query_inference.rs +14 -5
- data/rust/parser/src/build/read_model.rs +4 -1
- data/rust/parser/src/build/references.rs +4 -3
- data/rust/parser/src/canonical.rs +34 -15
- data/rust/parser/src/emit.rs +187 -105
- data/rust/parser/src/expr/ast_json.rs +134 -42
- data/rust/parser/src/expr/evaluator.rs +27 -9
- data/rust/parser/src/expr/mod.rs +54 -13
- data/rust/parser/src/expr/resolver.rs +299 -92
- data/rust/parser/src/ir.rs +35 -3
- data/rust/parser/src/keywords.rs +7168 -516
- data/rust/parser/src/lex.rs +11 -25
- data/rust/parser/src/lib.rs +1 -0
- data/rust/parser/src/main.rs +65 -6
- data/rust/parser/src/parse/adapter.rs +157 -0
- data/rust/parser/src/parse/aggregate.rs +19 -10
- data/rust/parser/src/parse/chapter.rs +16 -17
- data/rust/parser/src/parse/command.rs +35 -16
- data/rust/parser/src/parse/domain_port.rs +4 -7
- data/rust/parser/src/parse/entity.rs +21 -11
- data/rust/parser/src/parse/hecksagon.rs +10 -4
- data/rust/parser/src/parse/lifecycle.rs +9 -5
- data/rust/parser/src/parse/mod.rs +22 -91
- data/rust/parser/src/parse/needs.rs +21 -12
- data/rust/parser/src/parse/policy.rs +2 -2
- data/rust/parser/src/parse/process_manager.rs +9 -11
- data/rust/parser/src/parse/query.rs +8 -4
- data/rust/parser/src/parse/read_model.rs +57 -16
- data/rust/parser/src/parse/value_object.rs +7 -2
- data/rust/parser/src/parse/world.rs +322 -0
- data/rust/parser/src/ruby_value.rs +7 -6
- data/rust/src/exemplar/json.rs +4 -4
- data/rust/src/exemplar/mutations.rs +18 -0
- data/rust/src/exemplar/read_models.rs +1 -10
- data/rust/src/kernel/attribute_shapes/composite.rs +54 -14
- data/rust/src/kernel/attribute_shapes/list.rs +2 -2
- data/rust/src/kernel/attribute_shapes/mod.rs +8 -8
- data/rust/src/kernel/attribute_shapes/scalar.rs +2 -2
- data/rust/src/kernel/cli.rs +229 -112
- data/rust/src/kernel/dispatch.rs +323 -115
- data/rust/src/kernel/dispatch_guard_tests.rs +327 -65
- data/rust/src/kernel/expr.rs +203 -86
- data/rust/src/kernel/expression_operators/arithmetic.rs +138 -25
- data/rust/src/kernel/expression_operators/comparison.rs +87 -2
- data/rust/src/kernel/expression_operators/enumeration.rs +106 -22
- data/rust/src/kernel/expression_operators/logical.rs +9 -3
- data/rust/src/kernel/expression_operators/membership.rs +3 -1
- data/rust/src/kernel/expression_operators/mod.rs +34 -21
- data/rust/src/kernel/expression_operators/pattern_match.rs +46 -11
- data/rust/src/kernel/expression_operators/positional.rs +84 -30
- data/rust/src/kernel/expression_operators/presence.rs +52 -13
- data/rust/src/kernel/expression_operators/sign_test.rs +3 -1
- data/rust/src/kernel/expression_operators/sized.rs +24 -8
- data/rust/src/kernel/expression_operators/text.rs +120 -27
- data/rust/src/kernel/expression_operators/to_string.rs +10 -4
- data/rust/src/kernel/json.rs +230 -148
- data/rust/src/kernel/mod.rs +46 -33
- data/rust/src/kernel/named_query.rs +63 -15
- data/rust/src/kernel/needs.rs +112 -25
- data/rust/src/kernel/orchestrate.rs +980 -581
- data/rust/src/kernel/orchestrate_guard_tests.rs +538 -140
- data/rust/src/kernel/pattern.rs +107 -34
- data/rust/src/kernel/query_comparators.rs +118 -41
- data/rust/src/kernel/query_ordering.rs +13 -5
- data/rust/src/kernel/read_model.rs +479 -181
- data/rust/src/kernel/reference_lookup.rs +122 -26
- data/rust/src/kernel/repository.rs +293 -66
- data/rust/src/kernel/routing.rs +58 -42
- data/rust/src/kernel/ruby_float.rs +139 -0
- data/rust/src/kernel/vocab/aggregate_dispatch_order.rs +57 -57
- data/rust/src/kernel/vocab/entity_dispatch_order.rs +54 -54
- data/rust/src/kernel/vocab/field_hint.rs +19 -25
- data/rust/src/kernel/vocab/mod.rs +6 -6
- data/rust/src/kernel/vocab/query_comparator.rs +23 -24
- data/rust/src/kernel/vocab/refusal_template.rs +501 -297
- data/rust/src/lib.rs +50 -0
- data/rust/src/main.rs +4 -1
- data/rust/web/Cargo.toml +36 -0
- data/rust/web/clippy.toml +4 -0
- metadata +82 -15
- data/examples/pizzas/bluebook/hecksagon/mock_stripe_payment_adapter.rb +0 -36
- data/lib/hecks/adapters/driven/google_authentication.adapter +0 -3
- data/lib/hecks/adapters/driven/google_authentication.rb +0 -99
- data/lib/hecks/adapters/driven/mock_stripe_adapter.adapter +0 -3
- data/lib/hecks/adapters/driven/mock_stripe_adapter.rb +0 -32
- data/lib/hecks/ports/authentication.port +0 -6
- data/lib/hecks/ports/authentication.rb +0 -67
- data/lib/hecks/ports/persistence/plugins/era/translation/approval_file.rb +0 -255
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +0 -22
- data/rust/host/src/approval.rs +0 -652
data/rust/host/src/rate_limit.rs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
//! Per-address sliding-window rate limits for the public write routes a stranger can abuse,
|
|
2
2
|
//! enforced in `server.rs` before dispatch so a refused request costs no Postgres lock or run.
|
|
3
3
|
|
|
4
|
+
use crate::crypto::constant_time_eq;
|
|
5
|
+
use crate::extension::RateRule;
|
|
4
6
|
use axum::http::header::{HeaderValue, CONTENT_TYPE, RETRY_AFTER};
|
|
5
7
|
use axum::http::{HeaderMap, HeaderName, StatusCode};
|
|
6
8
|
use axum::response::{IntoResponse, Response};
|
|
7
|
-
use crate::extension::RateRule;
|
|
8
9
|
use serde_json::json;
|
|
9
10
|
use std::collections::{BTreeMap, HashMap, VecDeque};
|
|
10
11
|
use std::net::{IpAddr, Ipv6Addr};
|
|
@@ -63,14 +64,24 @@ impl SlidingWindow {
|
|
|
63
64
|
let window = self.window;
|
|
64
65
|
match self.entries.get_mut(key) {
|
|
65
66
|
Some(entry) => {
|
|
66
|
-
while entry
|
|
67
|
+
while entry
|
|
68
|
+
.stamps
|
|
69
|
+
.front()
|
|
70
|
+
.is_some_and(|s| now.saturating_duration_since(*s) >= window)
|
|
71
|
+
{
|
|
67
72
|
entry.stamps.pop_front();
|
|
68
73
|
}
|
|
69
74
|
if entry.stamps.len() >= self.limit {
|
|
70
75
|
let oldest = entry.stamps.front().copied().unwrap_or(now);
|
|
71
76
|
let wait = window.saturating_sub(now.saturating_duration_since(oldest));
|
|
72
|
-
let retry_after_secs = u64::try_from(wait.as_millis().div_ceil(1000))
|
|
73
|
-
|
|
77
|
+
let retry_after_secs = u64::try_from(wait.as_millis().div_ceil(1000))
|
|
78
|
+
.unwrap_or(u64::MAX)
|
|
79
|
+
.max(1);
|
|
80
|
+
return Decision {
|
|
81
|
+
allowed: false,
|
|
82
|
+
remaining: 0,
|
|
83
|
+
retry_after_secs,
|
|
84
|
+
};
|
|
74
85
|
}
|
|
75
86
|
}
|
|
76
87
|
None => self.make_room(now),
|
|
@@ -86,20 +97,27 @@ impl SlidingWindow {
|
|
|
86
97
|
fn record(&mut self, key: &str, now: Instant) -> Decision {
|
|
87
98
|
let seq = self.next_seq;
|
|
88
99
|
self.next_seq += 1;
|
|
89
|
-
let remaining =
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
+
let remaining = if let Some(entry) = self.entries.get_mut(key) {
|
|
101
|
+
self.recency.remove(&entry.seq);
|
|
102
|
+
entry.seq = seq;
|
|
103
|
+
entry.stamps.push_back(now);
|
|
104
|
+
self.limit - entry.stamps.len()
|
|
105
|
+
} else {
|
|
106
|
+
self.entries.insert(
|
|
107
|
+
key.to_string(),
|
|
108
|
+
Entry {
|
|
109
|
+
stamps: VecDeque::from([now]),
|
|
110
|
+
seq,
|
|
111
|
+
},
|
|
112
|
+
);
|
|
113
|
+
self.limit - 1
|
|
100
114
|
};
|
|
101
115
|
self.recency.insert(seq, key.to_string());
|
|
102
|
-
Decision {
|
|
116
|
+
Decision {
|
|
117
|
+
allowed: true,
|
|
118
|
+
remaining,
|
|
119
|
+
retry_after_secs: 0,
|
|
120
|
+
}
|
|
103
121
|
}
|
|
104
122
|
|
|
105
123
|
// Called before adding a new key: sweeps keys whose last request left the window, and only
|
|
@@ -123,7 +141,9 @@ impl SlidingWindow {
|
|
|
123
141
|
self.entries.remove(&key);
|
|
124
142
|
}
|
|
125
143
|
while self.entries.len() >= self.max_keys {
|
|
126
|
-
let Some((_, key)) = self.recency.pop_first() else {
|
|
144
|
+
let Some((_, key)) = self.recency.pop_first() else {
|
|
145
|
+
break;
|
|
146
|
+
};
|
|
127
147
|
self.entries.remove(&key);
|
|
128
148
|
}
|
|
129
149
|
}
|
|
@@ -155,11 +175,19 @@ impl IpNet {
|
|
|
155
175
|
pub fn contains(&self, ip: IpAddr) -> bool {
|
|
156
176
|
match (self.addr, ip.to_canonical()) {
|
|
157
177
|
(IpAddr::V4(net), IpAddr::V4(ip)) => {
|
|
158
|
-
let mask = if self.prefix == 0 {
|
|
178
|
+
let mask = if self.prefix == 0 {
|
|
179
|
+
0
|
|
180
|
+
} else {
|
|
181
|
+
u32::MAX << (32 - u32::from(self.prefix))
|
|
182
|
+
};
|
|
159
183
|
u32::from(net) & mask == u32::from(ip) & mask
|
|
160
184
|
}
|
|
161
185
|
(IpAddr::V6(net), IpAddr::V6(ip)) => {
|
|
162
|
-
let mask = if self.prefix == 0 {
|
|
186
|
+
let mask = if self.prefix == 0 {
|
|
187
|
+
0
|
|
188
|
+
} else {
|
|
189
|
+
u128::MAX << (128 - u32::from(self.prefix))
|
|
190
|
+
};
|
|
163
191
|
u128::from(net) & mask == u128::from(ip) & mask
|
|
164
192
|
}
|
|
165
193
|
_ => false,
|
|
@@ -183,12 +211,22 @@ pub struct ProxyTrust {
|
|
|
183
211
|
impl ProxyTrust {
|
|
184
212
|
// `None` means the client address could not be determined safely, rather than guessed;
|
|
185
213
|
// callers then share one bucket. The forwarded header is read only from a trusted source.
|
|
186
|
-
pub fn client_ip(
|
|
214
|
+
pub fn client_ip(
|
|
215
|
+
&self,
|
|
216
|
+
peer: Option<IpAddr>,
|
|
217
|
+
forwarded_for: Option<&str>,
|
|
218
|
+
presented_secret: Option<&str>,
|
|
219
|
+
) -> Option<IpAddr> {
|
|
187
220
|
let peer = peer.map(|p| p.to_canonical());
|
|
188
221
|
if !self.trusts(peer, presented_secret) {
|
|
189
222
|
return peer;
|
|
190
223
|
}
|
|
191
|
-
let entries: Vec<&str> = forwarded_for
|
|
224
|
+
let entries: Vec<&str> = forwarded_for
|
|
225
|
+
.unwrap_or("")
|
|
226
|
+
.split(',')
|
|
227
|
+
.map(str::trim)
|
|
228
|
+
.filter(|e| !e.is_empty())
|
|
229
|
+
.collect();
|
|
192
230
|
if entries.is_empty() {
|
|
193
231
|
return peer;
|
|
194
232
|
}
|
|
@@ -203,17 +241,15 @@ impl ProxyTrust {
|
|
|
203
241
|
|
|
204
242
|
fn trusts(&self, peer: Option<IpAddr>, presented_secret: Option<&str>) -> bool {
|
|
205
243
|
let secret_matches = match (&self.auth, presented_secret) {
|
|
206
|
-
(Some(auth), Some(presented)) =>
|
|
244
|
+
(Some(auth), Some(presented)) => {
|
|
245
|
+
constant_time_eq(auth.secret.as_bytes(), presented.as_bytes())
|
|
246
|
+
}
|
|
207
247
|
_ => false,
|
|
208
248
|
};
|
|
209
249
|
secret_matches || peer.is_some_and(|p| self.networks.iter().any(|net| net.contains(p)))
|
|
210
250
|
}
|
|
211
251
|
}
|
|
212
252
|
|
|
213
|
-
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
|
|
214
|
-
a.len() == b.len() && a.iter().zip(b).fold(0u8, |diff, (x, y)| diff | (x ^ y)) == 0
|
|
215
|
-
}
|
|
216
|
-
|
|
217
253
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
218
254
|
pub struct Config {
|
|
219
255
|
enabled: bool,
|
|
@@ -230,28 +266,53 @@ impl Config {
|
|
|
230
266
|
pub fn from_lookup(get: impl Fn(&str) -> Option<String>) -> (Self, Vec<String>) {
|
|
231
267
|
let mut warnings = Vec::new();
|
|
232
268
|
let mut number = |name: &str, default: u64| -> u64 {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
269
|
+
get(name)
|
|
270
|
+
.map(|v| v.trim().to_string())
|
|
271
|
+
.filter(|v| !v.is_empty())
|
|
272
|
+
.map_or(default, |raw| {
|
|
273
|
+
raw.parse::<u64>()
|
|
274
|
+
.ok()
|
|
275
|
+
.filter(|n| *n >= 1)
|
|
276
|
+
.unwrap_or_else(|| {
|
|
277
|
+
warnings.push(format!(
|
|
278
|
+
"{name}={raw:?} is not a positive whole number; using {default}"
|
|
279
|
+
));
|
|
280
|
+
default
|
|
281
|
+
})
|
|
282
|
+
})
|
|
240
283
|
};
|
|
241
284
|
let window = number("HECKS_RATE_LIMIT_WINDOW_SECONDS", DEFAULT_WINDOW_SECONDS);
|
|
242
285
|
let limits = crate::extension::rate_rules()
|
|
243
286
|
.iter()
|
|
244
|
-
.map(|rule|
|
|
287
|
+
.map(|rule| {
|
|
288
|
+
(
|
|
289
|
+
rule.name,
|
|
290
|
+
usize::try_from(number(rule.limit_env, rule.default_limit as u64))
|
|
291
|
+
.unwrap_or(usize::MAX),
|
|
292
|
+
)
|
|
293
|
+
})
|
|
245
294
|
.collect();
|
|
246
295
|
let max_keys = number("HECKS_RATE_LIMIT_MAX_KEYS", DEFAULT_MAX_KEYS as u64);
|
|
247
|
-
let hops = get("HECKS_TRUSTED_PROXY_HOPS")
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
296
|
+
let hops = get("HECKS_TRUSTED_PROXY_HOPS")
|
|
297
|
+
.map(|v| v.trim().to_string())
|
|
298
|
+
.filter(|v| !v.is_empty())
|
|
299
|
+
.map_or(0, |raw| {
|
|
300
|
+
raw.parse::<usize>().unwrap_or_else(|_| {
|
|
301
|
+
warnings.push(format!(
|
|
302
|
+
"HECKS_TRUSTED_PROXY_HOPS={raw:?} is not a whole number; using 0"
|
|
303
|
+
));
|
|
304
|
+
0
|
|
305
|
+
})
|
|
306
|
+
});
|
|
307
|
+
let networks = parse_networks(
|
|
308
|
+
get("HECKS_TRUSTED_PROXIES").as_deref().unwrap_or(""),
|
|
309
|
+
&mut warnings,
|
|
310
|
+
);
|
|
311
|
+
let auth = parse_proxy_auth(
|
|
312
|
+
get("HECKS_PROXY_AUTH_HEADER"),
|
|
313
|
+
get("HECKS_PROXY_AUTH_SECRET"),
|
|
314
|
+
&mut warnings,
|
|
315
|
+
);
|
|
255
316
|
let enabled = !get("HECKS_RATE_LIMIT").is_some_and(|v| is_off(&v));
|
|
256
317
|
|
|
257
318
|
let config = Self {
|
|
@@ -259,13 +320,20 @@ impl Config {
|
|
|
259
320
|
window: Duration::from_secs(window),
|
|
260
321
|
limits,
|
|
261
322
|
max_keys: usize::try_from(max_keys).unwrap_or(usize::MAX),
|
|
262
|
-
trust: ProxyTrust {
|
|
323
|
+
trust: ProxyTrust {
|
|
324
|
+
networks,
|
|
325
|
+
hops,
|
|
326
|
+
auth,
|
|
327
|
+
},
|
|
263
328
|
};
|
|
264
329
|
(config, warnings)
|
|
265
330
|
}
|
|
266
331
|
|
|
267
332
|
fn limit_for(&self, rule: &RateRule) -> usize {
|
|
268
|
-
self.limits
|
|
333
|
+
self.limits
|
|
334
|
+
.get(rule.name)
|
|
335
|
+
.copied()
|
|
336
|
+
.unwrap_or(rule.default_limit)
|
|
269
337
|
}
|
|
270
338
|
|
|
271
339
|
#[cfg(test)]
|
|
@@ -275,7 +343,10 @@ impl Config {
|
|
|
275
343
|
}
|
|
276
344
|
|
|
277
345
|
fn is_off(value: &str) -> bool {
|
|
278
|
-
matches!(
|
|
346
|
+
matches!(
|
|
347
|
+
value.trim().to_ascii_lowercase().as_str(),
|
|
348
|
+
"off" | "false" | "0" | "no" | "disabled"
|
|
349
|
+
)
|
|
279
350
|
}
|
|
280
351
|
|
|
281
352
|
fn parse_networks(list: &str, warnings: &mut Vec<String>) -> Vec<IpNet> {
|
|
@@ -292,18 +363,26 @@ fn parse_networks(list: &str, warnings: &mut Vec<String>) -> Vec<IpNet> {
|
|
|
292
363
|
.collect()
|
|
293
364
|
}
|
|
294
365
|
|
|
295
|
-
fn parse_proxy_auth(
|
|
296
|
-
|
|
366
|
+
fn parse_proxy_auth(
|
|
367
|
+
header: Option<String>,
|
|
368
|
+
secret: Option<String>,
|
|
369
|
+
warnings: &mut Vec<String>,
|
|
370
|
+
) -> Option<ProxyAuth> {
|
|
371
|
+
let header = header
|
|
372
|
+
.map(|v| v.trim().to_string())
|
|
373
|
+
.filter(|v| !v.is_empty());
|
|
297
374
|
let secret = secret.filter(|v| !v.is_empty());
|
|
298
375
|
match (header, secret) {
|
|
299
376
|
(None, None) => None,
|
|
300
|
-
(Some(header), Some(secret)) =>
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
377
|
+
(Some(header), Some(secret)) => {
|
|
378
|
+
HeaderName::from_bytes(header.to_ascii_lowercase().as_bytes()).map_or_else(
|
|
379
|
+
|_| {
|
|
380
|
+
warnings.push(format!("HECKS_PROXY_AUTH_HEADER {header:?} is not a valid header name; proxy authentication is off"));
|
|
381
|
+
None
|
|
382
|
+
},
|
|
383
|
+
|name| Some(ProxyAuth { header: name, secret }),
|
|
384
|
+
)
|
|
385
|
+
}
|
|
307
386
|
_ => {
|
|
308
387
|
warnings.push("HECKS_PROXY_AUTH_HEADER and HECKS_PROXY_AUTH_SECRET must both be set; proxy authentication is off".to_string());
|
|
309
388
|
None
|
|
@@ -328,7 +407,11 @@ pub struct RateLimits {
|
|
|
328
407
|
|
|
329
408
|
impl RateLimits {
|
|
330
409
|
pub fn new(config: Config) -> Self {
|
|
331
|
-
Self {
|
|
410
|
+
Self {
|
|
411
|
+
config,
|
|
412
|
+
windows: Mutex::new(HashMap::new()),
|
|
413
|
+
warned_untrusted_proxy: AtomicBool::new(false),
|
|
414
|
+
}
|
|
332
415
|
}
|
|
333
416
|
|
|
334
417
|
pub fn from_env() -> Self {
|
|
@@ -351,47 +434,85 @@ impl RateLimits {
|
|
|
351
434
|
}
|
|
352
435
|
|
|
353
436
|
// A route that is not limited is always `Allowed`.
|
|
354
|
-
pub fn check(
|
|
437
|
+
pub fn check(
|
|
438
|
+
&self,
|
|
439
|
+
method: &str,
|
|
440
|
+
path: &str,
|
|
441
|
+
headers: &HeaderMap,
|
|
442
|
+
peer: Option<IpAddr>,
|
|
443
|
+
) -> Verdict {
|
|
355
444
|
self.check_at(method, path, headers, peer, Instant::now())
|
|
356
445
|
}
|
|
357
446
|
|
|
358
447
|
// `check` with the clock supplied, so tests need not sleep.
|
|
359
|
-
pub fn check_at(
|
|
360
|
-
|
|
448
|
+
pub fn check_at(
|
|
449
|
+
&self,
|
|
450
|
+
method: &str,
|
|
451
|
+
path: &str,
|
|
452
|
+
headers: &HeaderMap,
|
|
453
|
+
peer: Option<IpAddr>,
|
|
454
|
+
now: Instant,
|
|
455
|
+
) -> Verdict {
|
|
456
|
+
let Some(rule) =
|
|
457
|
+
crate::extension::rate_rule_for(method, path).filter(|_| self.config.enabled)
|
|
458
|
+
else {
|
|
361
459
|
return Verdict::Allowed;
|
|
362
460
|
};
|
|
363
461
|
let key = self.client_key(headers, peer);
|
|
364
462
|
let decision = {
|
|
365
|
-
let mut windows = self
|
|
463
|
+
let mut windows = self
|
|
464
|
+
.windows
|
|
465
|
+
.lock()
|
|
466
|
+
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
366
467
|
let config = &self.config;
|
|
367
468
|
windows
|
|
368
469
|
.entry(rule.name)
|
|
369
|
-
.or_insert_with(||
|
|
470
|
+
.or_insert_with(|| {
|
|
471
|
+
SlidingWindow::new(config.limit_for(&rule), config.window, config.max_keys)
|
|
472
|
+
})
|
|
370
473
|
.check(&key, now)
|
|
371
474
|
};
|
|
372
475
|
if decision.allowed {
|
|
373
476
|
Verdict::Allowed
|
|
374
477
|
} else {
|
|
375
|
-
Verdict::Limited {
|
|
478
|
+
Verdict::Limited {
|
|
479
|
+
retry_after_secs: decision.retry_after_secs,
|
|
480
|
+
}
|
|
376
481
|
}
|
|
377
482
|
}
|
|
378
483
|
|
|
379
484
|
fn client_key(&self, headers: &HeaderMap, peer: Option<IpAddr>) -> String {
|
|
380
485
|
let forwarded_for = joined_header(headers, "x-forwarded-for");
|
|
381
|
-
let presented = self
|
|
486
|
+
let presented = self
|
|
487
|
+
.config
|
|
488
|
+
.trust
|
|
489
|
+
.auth
|
|
490
|
+
.as_ref()
|
|
491
|
+
.and_then(|auth| headers.get(&auth.header))
|
|
492
|
+
.and_then(|v| v.to_str().ok());
|
|
382
493
|
self.warn_if_proxy_untrusted(forwarded_for.as_deref(), peer, presented);
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
494
|
+
self.config
|
|
495
|
+
.trust
|
|
496
|
+
.client_ip(peer, forwarded_for.as_deref(), presented)
|
|
497
|
+
.map_or_else(|| UNKNOWN_CLIENT.to_string(), bucket_key)
|
|
387
498
|
}
|
|
388
499
|
|
|
389
500
|
// A forwarded header from a private peer that nothing trusts means a proxy sits in front of
|
|
390
501
|
// the host and every visitor is being counted as that proxy. Said once, since it repeats on
|
|
391
502
|
// every request.
|
|
392
|
-
fn warn_if_proxy_untrusted(
|
|
393
|
-
|
|
394
|
-
|
|
503
|
+
fn warn_if_proxy_untrusted(
|
|
504
|
+
&self,
|
|
505
|
+
forwarded_for: Option<&str>,
|
|
506
|
+
peer: Option<IpAddr>,
|
|
507
|
+
presented: Option<&str>,
|
|
508
|
+
) {
|
|
509
|
+
let Some(peer) = peer.map(|p| p.to_canonical()) else {
|
|
510
|
+
return;
|
|
511
|
+
};
|
|
512
|
+
if forwarded_for.is_none()
|
|
513
|
+
|| !is_private(peer)
|
|
514
|
+
|| self.config.trust.trusts(Some(peer), presented)
|
|
515
|
+
{
|
|
395
516
|
return;
|
|
396
517
|
}
|
|
397
518
|
if !self.warned_untrusted_proxy.swap(true, Ordering::Relaxed) {
|
|
@@ -409,7 +530,11 @@ impl RateLimits {
|
|
|
409
530
|
|
|
410
531
|
// Several X-Forwarded-For header lines mean the same as one comma-joined line.
|
|
411
532
|
fn joined_header(headers: &HeaderMap, name: &str) -> Option<String> {
|
|
412
|
-
let values: Vec<String> = headers
|
|
533
|
+
let values: Vec<String> = headers
|
|
534
|
+
.get_all(name)
|
|
535
|
+
.iter()
|
|
536
|
+
.map(|v| String::from_utf8_lossy(v.as_bytes()).into_owned())
|
|
537
|
+
.collect();
|
|
413
538
|
if values.is_empty() {
|
|
414
539
|
None
|
|
415
540
|
} else {
|
|
@@ -429,7 +554,7 @@ fn bucket_key(ip: IpAddr) -> String {
|
|
|
429
554
|
}
|
|
430
555
|
}
|
|
431
556
|
|
|
432
|
-
fn is_private(ip: IpAddr) -> bool {
|
|
557
|
+
const fn is_private(ip: IpAddr) -> bool {
|
|
433
558
|
match ip {
|
|
434
559
|
IpAddr::V4(v4) => v4.is_private() || v4.is_loopback() || v4.is_link_local(),
|
|
435
560
|
IpAddr::V6(v6) => v6.is_loopback() || (v6.segments()[0] & 0xfe00) == 0xfc00,
|
|
@@ -461,12 +586,18 @@ mod tests {
|
|
|
461
586
|
ProxyTrust {
|
|
462
587
|
networks: networks.iter().map(|n| IpNet::parse(n).unwrap()).collect(),
|
|
463
588
|
hops,
|
|
464
|
-
auth: auth.map(|(header, secret)| ProxyAuth {
|
|
589
|
+
auth: auth.map(|(header, secret)| ProxyAuth {
|
|
590
|
+
header: HeaderName::from_bytes(header.as_bytes()).unwrap(),
|
|
591
|
+
secret: secret.to_string(),
|
|
592
|
+
}),
|
|
465
593
|
}
|
|
466
594
|
}
|
|
467
595
|
|
|
468
596
|
fn config_from(pairs: &[(&str, &str)]) -> (Config, Vec<String>) {
|
|
469
|
-
let map: HashMap<String, String> = pairs
|
|
597
|
+
let map: HashMap<String, String> = pairs
|
|
598
|
+
.iter()
|
|
599
|
+
.map(|(k, v)| (k.to_string(), v.to_string()))
|
|
600
|
+
.collect();
|
|
470
601
|
Config::from_lookup(|name| map.get(name).cloned())
|
|
471
602
|
}
|
|
472
603
|
|
|
@@ -477,7 +608,10 @@ mod tests {
|
|
|
477
608
|
fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
|
|
478
609
|
let mut map = HeaderMap::new();
|
|
479
610
|
for (name, value) in pairs {
|
|
480
|
-
map.append(
|
|
611
|
+
map.append(
|
|
612
|
+
HeaderName::from_bytes(name.as_bytes()).unwrap(),
|
|
613
|
+
HeaderValue::from_str(value).unwrap(),
|
|
614
|
+
);
|
|
481
615
|
}
|
|
482
616
|
map
|
|
483
617
|
}
|
|
@@ -486,9 +620,22 @@ mod tests {
|
|
|
486
620
|
fn allows_up_to_the_limit_then_refuses_with_the_seconds_until_the_oldest_hit_expires() {
|
|
487
621
|
let start = Instant::now();
|
|
488
622
|
let mut window = SlidingWindow::new(3, HOUR, 100);
|
|
489
|
-
assert_eq!(
|
|
490
|
-
|
|
491
|
-
|
|
623
|
+
assert_eq!(
|
|
624
|
+
window.check("a", start),
|
|
625
|
+
Decision {
|
|
626
|
+
allowed: true,
|
|
627
|
+
remaining: 2,
|
|
628
|
+
retry_after_secs: 0
|
|
629
|
+
}
|
|
630
|
+
);
|
|
631
|
+
assert_eq!(
|
|
632
|
+
window.check("a", start + Duration::from_secs(10)).remaining,
|
|
633
|
+
1
|
|
634
|
+
);
|
|
635
|
+
assert_eq!(
|
|
636
|
+
window.check("a", start + Duration::from_secs(20)).remaining,
|
|
637
|
+
0
|
|
638
|
+
);
|
|
492
639
|
|
|
493
640
|
let refused = window.check("a", start + Duration::from_secs(30));
|
|
494
641
|
assert!(!refused.allowed);
|
|
@@ -500,12 +647,18 @@ mod tests {
|
|
|
500
647
|
let start = Instant::now();
|
|
501
648
|
let mut window = SlidingWindow::new(2, HOUR, 100);
|
|
502
649
|
window.check("a", start);
|
|
503
|
-
window.check("a", start + Duration::
|
|
650
|
+
window.check("a", start + Duration::from_mins(30));
|
|
504
651
|
assert!(!window.check("a", start + Duration::from_secs(1801)).allowed);
|
|
505
652
|
|
|
506
653
|
let later = start + Duration::from_secs(3601);
|
|
507
|
-
assert!(
|
|
508
|
-
|
|
654
|
+
assert!(
|
|
655
|
+
window.check("a", later).allowed,
|
|
656
|
+
"the first hit is past the window"
|
|
657
|
+
);
|
|
658
|
+
assert!(
|
|
659
|
+
!window.check("a", later).allowed,
|
|
660
|
+
"the second is still inside it"
|
|
661
|
+
);
|
|
509
662
|
}
|
|
510
663
|
|
|
511
664
|
#[test]
|
|
@@ -523,9 +676,16 @@ mod tests {
|
|
|
523
676
|
let mut window = SlidingWindow::new(1, HOUR, 100);
|
|
524
677
|
window.check("a", start);
|
|
525
678
|
for second in 1..=50 {
|
|
526
|
-
assert!(
|
|
679
|
+
assert!(
|
|
680
|
+
!window
|
|
681
|
+
.check("a", start + Duration::from_secs(second))
|
|
682
|
+
.allowed
|
|
683
|
+
);
|
|
527
684
|
}
|
|
528
|
-
assert!(
|
|
685
|
+
assert!(
|
|
686
|
+
window.check("a", start + HOUR).allowed,
|
|
687
|
+
"the one recorded hit expired on schedule"
|
|
688
|
+
);
|
|
529
689
|
}
|
|
530
690
|
|
|
531
691
|
#[test]
|
|
@@ -533,8 +693,18 @@ mod tests {
|
|
|
533
693
|
let start = Instant::now();
|
|
534
694
|
let mut window = SlidingWindow::new(1, Duration::from_millis(1500), 100);
|
|
535
695
|
window.check("a", start);
|
|
536
|
-
assert_eq!(
|
|
537
|
-
|
|
696
|
+
assert_eq!(
|
|
697
|
+
window
|
|
698
|
+
.check("a", start + Duration::from_millis(1))
|
|
699
|
+
.retry_after_secs,
|
|
700
|
+
2
|
|
701
|
+
);
|
|
702
|
+
assert_eq!(
|
|
703
|
+
window
|
|
704
|
+
.check("a", start + Duration::from_millis(1499))
|
|
705
|
+
.retry_after_secs,
|
|
706
|
+
1
|
|
707
|
+
);
|
|
538
708
|
}
|
|
539
709
|
|
|
540
710
|
#[test]
|
|
@@ -560,7 +730,10 @@ mod tests {
|
|
|
560
730
|
}
|
|
561
731
|
assert_eq!(window.len(), 3);
|
|
562
732
|
let now = start + Duration::from_millis(10);
|
|
563
|
-
assert!(
|
|
733
|
+
assert!(
|
|
734
|
+
window.check("a", now).allowed,
|
|
735
|
+
"a was evicted, so it starts fresh"
|
|
736
|
+
);
|
|
564
737
|
assert!(!window.check("e", now).allowed, "e is still tracked");
|
|
565
738
|
}
|
|
566
739
|
|
|
@@ -587,15 +760,31 @@ mod tests {
|
|
|
587
760
|
let v6 = IpNet::parse("fd00::/8").unwrap();
|
|
588
761
|
assert!(v6.contains(ip("fd12::1")));
|
|
589
762
|
assert!(!v6.contains(ip("2001:db8::1")));
|
|
590
|
-
assert!(
|
|
763
|
+
assert!(
|
|
764
|
+
!v6.contains(ip("10.0.0.1")),
|
|
765
|
+
"families never match each other"
|
|
766
|
+
);
|
|
591
767
|
|
|
592
768
|
assert!(IpNet::parse("0.0.0.0/0").unwrap().contains(ip("8.8.8.8")));
|
|
593
|
-
assert!(
|
|
769
|
+
assert!(
|
|
770
|
+
IpNet::parse("10.0.0.0/8")
|
|
771
|
+
.unwrap()
|
|
772
|
+
.contains(ip("::ffff:10.1.1.1")),
|
|
773
|
+
"a mapped address is its IPv4 form"
|
|
774
|
+
);
|
|
594
775
|
}
|
|
595
776
|
|
|
596
777
|
#[test]
|
|
597
778
|
fn rejects_malformed_ranges() {
|
|
598
|
-
for bad in [
|
|
779
|
+
for bad in [
|
|
780
|
+
"",
|
|
781
|
+
"banana",
|
|
782
|
+
"10.0.0.0/33",
|
|
783
|
+
"::1/129",
|
|
784
|
+
"10.0.0.0/",
|
|
785
|
+
"10.0.0/8",
|
|
786
|
+
"/8",
|
|
787
|
+
] {
|
|
599
788
|
assert!(IpNet::parse(bad).is_none(), "{bad:?}");
|
|
600
789
|
}
|
|
601
790
|
}
|
|
@@ -603,14 +792,18 @@ mod tests {
|
|
|
603
792
|
#[test]
|
|
604
793
|
fn with_nothing_trusted_the_peer_is_the_client_and_the_header_is_ignored() {
|
|
605
794
|
let none = trust(&[], 0, None);
|
|
606
|
-
assert_eq!(
|
|
795
|
+
assert_eq!(
|
|
796
|
+
none.client_ip(Some(ip("198.51.100.4")), Some("6.6.6.6"), None),
|
|
797
|
+
Some(ip("198.51.100.4"))
|
|
798
|
+
);
|
|
607
799
|
assert_eq!(none.client_ip(None, Some("6.6.6.6"), None), None);
|
|
608
800
|
}
|
|
609
801
|
|
|
610
802
|
#[test]
|
|
611
803
|
fn an_untrusted_peer_cannot_choose_its_own_key_by_sending_a_header() {
|
|
612
804
|
let proxies = trust(&["10.0.0.0/8"], 0, None);
|
|
613
|
-
let from_stranger =
|
|
805
|
+
let from_stranger =
|
|
806
|
+
|spoof: &str| proxies.client_ip(Some(ip("198.51.100.4")), Some(spoof), None);
|
|
614
807
|
assert_eq!(from_stranger("1.1.1.1"), from_stranger("2.2.2.2"));
|
|
615
808
|
assert_eq!(from_stranger("1.1.1.1"), Some(ip("198.51.100.4")));
|
|
616
809
|
}
|
|
@@ -618,20 +811,36 @@ mod tests {
|
|
|
618
811
|
#[test]
|
|
619
812
|
fn a_trusted_peer_is_believed_and_the_rightmost_entry_is_the_client() {
|
|
620
813
|
let proxies = trust(&["10.0.0.0/8"], 0, None);
|
|
621
|
-
assert_eq!(
|
|
814
|
+
assert_eq!(
|
|
815
|
+
proxies.client_ip(
|
|
816
|
+
Some(ip("10.0.0.5")),
|
|
817
|
+
Some(&format!("6.6.6.6, {VISITOR}")),
|
|
818
|
+
None
|
|
819
|
+
),
|
|
820
|
+
Some(ip(VISITOR))
|
|
821
|
+
);
|
|
622
822
|
}
|
|
623
823
|
|
|
624
824
|
#[test]
|
|
625
825
|
fn a_spoofed_leading_entry_is_never_reached() {
|
|
626
826
|
let proxies = trust(&["10.0.0.0/8"], 0, None);
|
|
627
827
|
let forwarded = format!("6.6.6.6, 7.7.7.7, {VISITOR}, 10.0.0.9");
|
|
628
|
-
assert_eq!(
|
|
828
|
+
assert_eq!(
|
|
829
|
+
proxies.client_ip(Some(ip("10.0.0.5")), Some(&forwarded), None),
|
|
830
|
+
Some(ip(VISITOR))
|
|
831
|
+
);
|
|
629
832
|
}
|
|
630
833
|
|
|
631
834
|
#[test]
|
|
632
835
|
fn a_visitor_cannot_pick_their_key_by_varying_the_spoofed_part() {
|
|
633
836
|
let proxies = trust(&["10.0.0.0/8"], 0, None);
|
|
634
|
-
let key = |spoof: &str|
|
|
837
|
+
let key = |spoof: &str| {
|
|
838
|
+
proxies.client_ip(
|
|
839
|
+
Some(ip("10.0.0.5")),
|
|
840
|
+
Some(&format!("{spoof}, {VISITOR}")),
|
|
841
|
+
None,
|
|
842
|
+
)
|
|
843
|
+
};
|
|
635
844
|
assert_eq!(key("1.1.1.1"), key("2.2.2.2"));
|
|
636
845
|
}
|
|
637
846
|
|
|
@@ -639,68 +848,121 @@ mod tests {
|
|
|
639
848
|
fn trusted_proxies_inside_the_header_are_skipped_from_the_right() {
|
|
640
849
|
let proxies = trust(&["10.0.0.0/8", "172.16.0.0/12"], 0, None);
|
|
641
850
|
let forwarded = format!("6.6.6.6, {VISITOR}, 172.16.0.3, 10.0.0.9");
|
|
642
|
-
assert_eq!(
|
|
851
|
+
assert_eq!(
|
|
852
|
+
proxies.client_ip(Some(ip("10.0.0.5")), Some(&forwarded), None),
|
|
853
|
+
Some(ip(VISITOR))
|
|
854
|
+
);
|
|
643
855
|
}
|
|
644
856
|
|
|
645
857
|
#[test]
|
|
646
858
|
fn the_shared_secret_authenticates_a_request_from_an_unlisted_peer() {
|
|
647
859
|
let cdn = trust(&[], 1, Some(("x-origin-secret", "s3cret")));
|
|
648
860
|
let forwarded = format!("6.6.6.6, {VISITOR}, {EDGE}");
|
|
649
|
-
assert_eq!(
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
861
|
+
assert_eq!(
|
|
862
|
+
cdn.client_ip(Some(ip("10.0.0.5")), Some(&forwarded), Some("s3cret")),
|
|
863
|
+
Some(ip(VISITOR))
|
|
864
|
+
);
|
|
865
|
+
assert_eq!(
|
|
866
|
+
cdn.client_ip(Some(ip("10.0.0.5")), Some(&forwarded), Some("wrong")),
|
|
867
|
+
Some(ip("10.0.0.5"))
|
|
868
|
+
);
|
|
869
|
+
assert_eq!(
|
|
870
|
+
cdn.client_ip(Some(ip("10.0.0.5")), Some(&forwarded), None),
|
|
871
|
+
Some(ip("10.0.0.5"))
|
|
872
|
+
);
|
|
873
|
+
assert_eq!(
|
|
874
|
+
cdn.client_ip(
|
|
875
|
+
Some(ip("10.0.0.5")),
|
|
876
|
+
Some(&forwarded),
|
|
877
|
+
Some("s3cret-and-more")
|
|
878
|
+
),
|
|
879
|
+
Some(ip("10.0.0.5"))
|
|
880
|
+
);
|
|
653
881
|
}
|
|
654
882
|
|
|
655
883
|
#[test]
|
|
656
884
|
fn hops_skip_the_rightmost_entries_added_by_unlisted_proxies() {
|
|
657
885
|
let cdn = trust(&[], 1, Some(("x-origin-secret", "s")));
|
|
658
886
|
let one = format!("6.6.6.6, 7.7.7.7, {VISITOR}, {EDGE}");
|
|
659
|
-
assert_eq!(
|
|
887
|
+
assert_eq!(
|
|
888
|
+
cdn.client_ip(None, Some(&one), Some("s")),
|
|
889
|
+
Some(ip(VISITOR))
|
|
890
|
+
);
|
|
660
891
|
|
|
661
892
|
let two = trust(&[], 2, Some(("x-origin-secret", "s")));
|
|
662
893
|
let forwarded = format!("6.6.6.6, {VISITOR}, {EDGE}, 130.176.2.2");
|
|
663
|
-
assert_eq!(
|
|
894
|
+
assert_eq!(
|
|
895
|
+
two.client_ip(None, Some(&forwarded), Some("s")),
|
|
896
|
+
Some(ip(VISITOR))
|
|
897
|
+
);
|
|
664
898
|
}
|
|
665
899
|
|
|
666
900
|
#[test]
|
|
667
901
|
fn a_header_shorter_than_the_walk_implies_yields_no_client() {
|
|
668
902
|
let cdn = trust(&[], 1, Some(("x-origin-secret", "s")));
|
|
669
|
-
assert_eq!(
|
|
903
|
+
assert_eq!(
|
|
904
|
+
cdn.client_ip(Some(ip("127.0.0.1")), Some("6.6.6.6"), Some("s")),
|
|
905
|
+
None
|
|
906
|
+
);
|
|
670
907
|
let listed = trust(&["10.0.0.0/8"], 0, None);
|
|
671
|
-
assert_eq!(
|
|
908
|
+
assert_eq!(
|
|
909
|
+
listed.client_ip(Some(ip("10.0.0.5")), Some("10.0.0.9, 10.0.0.8"), None),
|
|
910
|
+
None,
|
|
911
|
+
"every entry is one of ours"
|
|
912
|
+
);
|
|
672
913
|
}
|
|
673
914
|
|
|
674
915
|
#[test]
|
|
675
916
|
fn a_client_entry_that_is_not_an_address_yields_no_client() {
|
|
676
917
|
let proxies = trust(&["10.0.0.0/8"], 0, None);
|
|
677
|
-
assert_eq!(
|
|
918
|
+
assert_eq!(
|
|
919
|
+
proxies.client_ip(Some(ip("10.0.0.5")), Some("6.6.6.6, not-an-ip"), None),
|
|
920
|
+
None
|
|
921
|
+
);
|
|
678
922
|
}
|
|
679
923
|
|
|
680
924
|
#[test]
|
|
681
925
|
fn a_trusted_peer_with_no_header_is_its_own_client() {
|
|
682
926
|
let proxies = trust(&["127.0.0.1"], 0, None);
|
|
683
|
-
assert_eq!(
|
|
684
|
-
|
|
927
|
+
assert_eq!(
|
|
928
|
+
proxies.client_ip(Some(ip("127.0.0.1")), None, None),
|
|
929
|
+
Some(ip("127.0.0.1"))
|
|
930
|
+
);
|
|
931
|
+
assert_eq!(
|
|
932
|
+
proxies.client_ip(Some(ip("127.0.0.1")), Some(" , "), None),
|
|
933
|
+
Some(ip("127.0.0.1"))
|
|
934
|
+
);
|
|
685
935
|
}
|
|
686
936
|
|
|
687
937
|
#[test]
|
|
688
938
|
fn tolerates_spaces_empty_entries_and_ipv6() {
|
|
689
939
|
let proxies = trust(&["10.0.0.0/8"], 0, None);
|
|
690
940
|
let forwarded = " 9.9.9.9 ,, 2001:db8::1 , 10.0.0.9 ";
|
|
691
|
-
assert_eq!(
|
|
941
|
+
assert_eq!(
|
|
942
|
+
proxies.client_ip(Some(ip("10.0.0.5")), Some(forwarded), None),
|
|
943
|
+
Some(ip("2001:db8::1"))
|
|
944
|
+
);
|
|
692
945
|
}
|
|
693
946
|
|
|
694
947
|
#[test]
|
|
695
948
|
fn a_mapped_peer_is_matched_as_its_ipv4_form() {
|
|
696
949
|
let proxies = trust(&["10.0.0.0/8"], 0, None);
|
|
697
|
-
assert_eq!(
|
|
950
|
+
assert_eq!(
|
|
951
|
+
proxies.client_ip(Some(ip("::ffff:10.0.0.5")), Some(VISITOR), None),
|
|
952
|
+
Some(ip(VISITOR))
|
|
953
|
+
);
|
|
698
954
|
}
|
|
699
955
|
|
|
700
956
|
#[test]
|
|
701
957
|
fn ipv6_callers_are_bucketed_by_their_64() {
|
|
702
|
-
assert_eq!(
|
|
703
|
-
|
|
958
|
+
assert_eq!(
|
|
959
|
+
bucket_key(ip("2001:db8:1:2:aaaa:bbbb:cccc:dddd")),
|
|
960
|
+
bucket_key(ip("2001:db8:1:2::1"))
|
|
961
|
+
);
|
|
962
|
+
assert_ne!(
|
|
963
|
+
bucket_key(ip("2001:db8:1:2::1")),
|
|
964
|
+
bucket_key(ip("2001:db8:1:3::1"))
|
|
965
|
+
);
|
|
704
966
|
assert_eq!(bucket_key(ip("203.0.113.7")), "203.0.113.7");
|
|
705
967
|
}
|
|
706
968
|
|
|
@@ -710,7 +972,14 @@ mod tests {
|
|
|
710
972
|
assert!(warnings.is_empty());
|
|
711
973
|
assert!(config.enabled);
|
|
712
974
|
assert_eq!(config.window, HOUR);
|
|
713
|
-
assert_eq!(
|
|
975
|
+
assert_eq!(
|
|
976
|
+
(
|
|
977
|
+
config.limit("subscribe"),
|
|
978
|
+
config.limit("register"),
|
|
979
|
+
config.max_keys
|
|
980
|
+
),
|
|
981
|
+
(10, 15, 10_000)
|
|
982
|
+
);
|
|
714
983
|
assert_eq!(config.trust, ProxyTrust::default());
|
|
715
984
|
}
|
|
716
985
|
|
|
@@ -728,14 +997,31 @@ mod tests {
|
|
|
728
997
|
]);
|
|
729
998
|
assert!(warnings.is_empty(), "{warnings:?}");
|
|
730
999
|
assert_eq!(config.window, Duration::from_secs(60));
|
|
731
|
-
assert_eq!(
|
|
732
|
-
|
|
1000
|
+
assert_eq!(
|
|
1001
|
+
(
|
|
1002
|
+
config.limit("subscribe"),
|
|
1003
|
+
config.limit("register"),
|
|
1004
|
+
config.max_keys
|
|
1005
|
+
),
|
|
1006
|
+
(3, 4, 50)
|
|
1007
|
+
);
|
|
1008
|
+
assert_eq!(
|
|
1009
|
+
config.trust,
|
|
1010
|
+
trust(
|
|
1011
|
+
&["10.0.0.0/8", "127.0.0.1"],
|
|
1012
|
+
1,
|
|
1013
|
+
Some(("x-origin-secret", "s3cret"))
|
|
1014
|
+
)
|
|
1015
|
+
);
|
|
733
1016
|
}
|
|
734
1017
|
|
|
735
1018
|
#[test]
|
|
736
1019
|
fn off_values_turn_the_limits_off_and_anything_else_leaves_them_on() {
|
|
737
1020
|
for off in ["off", "OFF", " false ", "0", "no", "disabled"] {
|
|
738
|
-
assert!(
|
|
1021
|
+
assert!(
|
|
1022
|
+
!config_from(&[("HECKS_RATE_LIMIT", off)]).0.enabled,
|
|
1023
|
+
"{off:?}"
|
|
1024
|
+
);
|
|
739
1025
|
}
|
|
740
1026
|
for on in ["on", "true", "1", ""] {
|
|
741
1027
|
assert!(config_from(&[("HECKS_RATE_LIMIT", on)]).0.enabled, "{on:?}");
|
|
@@ -751,7 +1037,14 @@ mod tests {
|
|
|
751
1037
|
("HECKS_TRUSTED_PROXY_HOPS", "two"),
|
|
752
1038
|
("HECKS_TRUSTED_PROXIES", "10.0.0.0/8, banana"),
|
|
753
1039
|
]);
|
|
754
|
-
assert_eq!(
|
|
1040
|
+
assert_eq!(
|
|
1041
|
+
(
|
|
1042
|
+
config.limit("subscribe"),
|
|
1043
|
+
config.limit("register"),
|
|
1044
|
+
config.window
|
|
1045
|
+
),
|
|
1046
|
+
(10, 15, HOUR)
|
|
1047
|
+
);
|
|
755
1048
|
assert_eq!(config.trust.hops, 0);
|
|
756
1049
|
assert_eq!(config.trust.networks.len(), 1);
|
|
757
1050
|
assert_eq!(warnings.len(), 5, "{warnings:?}");
|
|
@@ -759,19 +1052,26 @@ mod tests {
|
|
|
759
1052
|
|
|
760
1053
|
#[test]
|
|
761
1054
|
fn proxy_authentication_needs_both_a_header_and_a_secret() {
|
|
762
|
-
for pairs in [
|
|
1055
|
+
for pairs in [
|
|
1056
|
+
&[("HECKS_PROXY_AUTH_HEADER", "x-secret")][..],
|
|
1057
|
+
&[("HECKS_PROXY_AUTH_SECRET", "s")][..],
|
|
1058
|
+
] {
|
|
763
1059
|
let (config, warnings) = config_from(pairs);
|
|
764
1060
|
assert!(config.trust.auth.is_none());
|
|
765
1061
|
assert_eq!(warnings.len(), 1);
|
|
766
1062
|
}
|
|
767
|
-
let (config, warnings) = config_from(&[
|
|
1063
|
+
let (config, warnings) = config_from(&[
|
|
1064
|
+
("HECKS_PROXY_AUTH_HEADER", "not a header"),
|
|
1065
|
+
("HECKS_PROXY_AUTH_SECRET", "s"),
|
|
1066
|
+
]);
|
|
768
1067
|
assert!(config.trust.auth.is_none());
|
|
769
1068
|
assert_eq!(warnings.len(), 1);
|
|
770
1069
|
}
|
|
771
1070
|
|
|
772
1071
|
#[test]
|
|
773
1072
|
fn only_the_public_write_routes_are_limited() {
|
|
774
|
-
let name =
|
|
1073
|
+
let name =
|
|
1074
|
+
|method, path| crate::extension::rate_rule_for(method, path).map(|rule| rule.name);
|
|
775
1075
|
assert_eq!(name("POST", "/newsletter/subscribers"), Some("subscribe"));
|
|
776
1076
|
assert_eq!(name("POST", "/registrations"), Some("register"));
|
|
777
1077
|
for (method, path) in [
|
|
@@ -786,31 +1086,68 @@ mod tests {
|
|
|
786
1086
|
("POST", "/members"),
|
|
787
1087
|
("POST", "/newsletter/subscribers/"),
|
|
788
1088
|
] {
|
|
789
|
-
assert_eq!(
|
|
1089
|
+
assert_eq!(
|
|
1090
|
+
crate::extension::rate_rule_for(method, path).map(|rule| rule.name),
|
|
1091
|
+
None,
|
|
1092
|
+
"{method} {path}"
|
|
1093
|
+
);
|
|
790
1094
|
}
|
|
791
1095
|
}
|
|
792
1096
|
|
|
793
1097
|
#[test]
|
|
794
1098
|
fn each_route_has_its_own_budget_per_client() {
|
|
795
|
-
let limits = limits(&[
|
|
1099
|
+
let limits = limits(&[
|
|
1100
|
+
("HECKS_RATE_LIMIT_SUBSCRIBE", "2"),
|
|
1101
|
+
("HECKS_RATE_LIMIT_REGISTER", "1"),
|
|
1102
|
+
]);
|
|
796
1103
|
let (none, peer, now) = (HeaderMap::new(), Some(ip("198.51.100.4")), Instant::now());
|
|
797
1104
|
let hit = |path: &str| limits.check_at("POST", path, &none, peer, now);
|
|
798
1105
|
|
|
799
1106
|
assert_eq!(hit("/newsletter/subscribers"), Verdict::Allowed);
|
|
800
1107
|
assert_eq!(hit("/newsletter/subscribers"), Verdict::Allowed);
|
|
801
|
-
assert_eq!(
|
|
802
|
-
|
|
803
|
-
|
|
1108
|
+
assert_eq!(
|
|
1109
|
+
hit("/newsletter/subscribers"),
|
|
1110
|
+
Verdict::Limited {
|
|
1111
|
+
retry_after_secs: 3600
|
|
1112
|
+
}
|
|
1113
|
+
);
|
|
1114
|
+
assert_eq!(
|
|
1115
|
+
hit("/registrations"),
|
|
1116
|
+
Verdict::Allowed,
|
|
1117
|
+
"subscribe being spent does not touch register"
|
|
1118
|
+
);
|
|
1119
|
+
assert_eq!(
|
|
1120
|
+
hit("/registrations"),
|
|
1121
|
+
Verdict::Limited {
|
|
1122
|
+
retry_after_secs: 3600
|
|
1123
|
+
}
|
|
1124
|
+
);
|
|
804
1125
|
}
|
|
805
1126
|
|
|
806
1127
|
#[test]
|
|
807
1128
|
fn the_window_rolls_over() {
|
|
808
|
-
let limits = limits(&[
|
|
1129
|
+
let limits = limits(&[
|
|
1130
|
+
("HECKS_RATE_LIMIT_REGISTER", "1"),
|
|
1131
|
+
("HECKS_RATE_LIMIT_WINDOW_SECONDS", "60"),
|
|
1132
|
+
]);
|
|
809
1133
|
let (none, peer, start) = (HeaderMap::new(), Some(ip("198.51.100.4")), Instant::now());
|
|
810
|
-
let at = |secs: u64|
|
|
1134
|
+
let at = |secs: u64| {
|
|
1135
|
+
limits.check_at(
|
|
1136
|
+
"POST",
|
|
1137
|
+
"/registrations",
|
|
1138
|
+
&none,
|
|
1139
|
+
peer,
|
|
1140
|
+
start + Duration::from_secs(secs),
|
|
1141
|
+
)
|
|
1142
|
+
};
|
|
811
1143
|
|
|
812
1144
|
assert_eq!(at(0), Verdict::Allowed);
|
|
813
|
-
assert_eq!(
|
|
1145
|
+
assert_eq!(
|
|
1146
|
+
at(10),
|
|
1147
|
+
Verdict::Limited {
|
|
1148
|
+
retry_after_secs: 50
|
|
1149
|
+
}
|
|
1150
|
+
);
|
|
814
1151
|
assert_eq!(at(60), Verdict::Allowed);
|
|
815
1152
|
}
|
|
816
1153
|
|
|
@@ -819,21 +1156,44 @@ mod tests {
|
|
|
819
1156
|
let on = limits(&[("HECKS_RATE_LIMIT_SUBSCRIBE", "1")]);
|
|
820
1157
|
let (none, peer, now) = (HeaderMap::new(), Some(ip("198.51.100.4")), Instant::now());
|
|
821
1158
|
for _ in 0..5 {
|
|
822
|
-
assert_eq!(
|
|
823
|
-
|
|
1159
|
+
assert_eq!(
|
|
1160
|
+
on.check_at("GET", "/newsletter/subscribers", &none, peer, now),
|
|
1161
|
+
Verdict::Allowed
|
|
1162
|
+
);
|
|
1163
|
+
assert_eq!(
|
|
1164
|
+
on.check_at("POST", "/webhooks/stripe", &none, peer, now),
|
|
1165
|
+
Verdict::Allowed
|
|
1166
|
+
);
|
|
824
1167
|
}
|
|
825
1168
|
|
|
826
|
-
let off = limits(&[
|
|
1169
|
+
let off = limits(&[
|
|
1170
|
+
("HECKS_RATE_LIMIT", "off"),
|
|
1171
|
+
("HECKS_RATE_LIMIT_SUBSCRIBE", "1"),
|
|
1172
|
+
]);
|
|
827
1173
|
for _ in 0..5 {
|
|
828
|
-
assert_eq!(
|
|
1174
|
+
assert_eq!(
|
|
1175
|
+
off.check_at("POST", "/newsletter/subscribers", &none, peer, now),
|
|
1176
|
+
Verdict::Allowed
|
|
1177
|
+
);
|
|
829
1178
|
}
|
|
830
1179
|
}
|
|
831
1180
|
|
|
832
1181
|
#[test]
|
|
833
1182
|
fn a_spoofed_header_from_an_untrusted_peer_does_not_reset_the_count() {
|
|
834
|
-
let limits = limits(&[
|
|
1183
|
+
let limits = limits(&[
|
|
1184
|
+
("HECKS_RATE_LIMIT_SUBSCRIBE", "1"),
|
|
1185
|
+
("HECKS_TRUSTED_PROXIES", "10.0.0.0/8"),
|
|
1186
|
+
]);
|
|
835
1187
|
let (peer, now) = (Some(ip("198.51.100.4")), Instant::now());
|
|
836
|
-
let send = |spoof: &str|
|
|
1188
|
+
let send = |spoof: &str| {
|
|
1189
|
+
limits.check_at(
|
|
1190
|
+
"POST",
|
|
1191
|
+
"/newsletter/subscribers",
|
|
1192
|
+
&headers(&[("x-forwarded-for", spoof)]),
|
|
1193
|
+
peer,
|
|
1194
|
+
now,
|
|
1195
|
+
)
|
|
1196
|
+
};
|
|
837
1197
|
|
|
838
1198
|
assert_eq!(send("1.1.1.1"), Verdict::Allowed);
|
|
839
1199
|
assert!(matches!(send("2.2.2.2"), Verdict::Limited { .. }));
|
|
@@ -850,31 +1210,65 @@ mod tests {
|
|
|
850
1210
|
let (alb, now) = (Some(ip("10.0.0.5")), Instant::now());
|
|
851
1211
|
let from = |spoof: &str, visitor: &str| {
|
|
852
1212
|
let forwarded = format!("{spoof}, {visitor}, {EDGE}");
|
|
853
|
-
limits.check_at(
|
|
1213
|
+
limits.check_at(
|
|
1214
|
+
"POST",
|
|
1215
|
+
"/registrations",
|
|
1216
|
+
&headers(&[
|
|
1217
|
+
("x-origin-secret", "s3cret"),
|
|
1218
|
+
("x-forwarded-for", &forwarded),
|
|
1219
|
+
]),
|
|
1220
|
+
alb,
|
|
1221
|
+
now,
|
|
1222
|
+
)
|
|
854
1223
|
};
|
|
855
1224
|
|
|
856
1225
|
assert_eq!(from("1.1.1.1", VISITOR), Verdict::Allowed);
|
|
857
|
-
assert!(
|
|
858
|
-
|
|
1226
|
+
assert!(
|
|
1227
|
+
matches!(from("2.2.2.2", VISITOR), Verdict::Limited { .. }),
|
|
1228
|
+
"a new spoofed entry is not a new visitor"
|
|
1229
|
+
);
|
|
1230
|
+
assert_eq!(
|
|
1231
|
+
from("2.2.2.2", "198.51.100.4"),
|
|
1232
|
+
Verdict::Allowed,
|
|
1233
|
+
"another visitor has their own count"
|
|
1234
|
+
);
|
|
859
1235
|
}
|
|
860
1236
|
|
|
861
1237
|
#[test]
|
|
862
1238
|
fn requests_whose_client_cannot_be_determined_share_one_bucket() {
|
|
863
|
-
let limits = limits(&[
|
|
1239
|
+
let limits = limits(&[
|
|
1240
|
+
("HECKS_RATE_LIMIT_REGISTER", "1"),
|
|
1241
|
+
("HECKS_TRUSTED_PROXIES", "10.0.0.0/8"),
|
|
1242
|
+
]);
|
|
864
1243
|
let now = Instant::now();
|
|
865
1244
|
let short = headers(&[("x-forwarded-for", "10.0.0.9")]);
|
|
866
|
-
assert_eq!(
|
|
867
|
-
|
|
1245
|
+
assert_eq!(
|
|
1246
|
+
limits.check_at("POST", "/registrations", &short, Some(ip("10.0.0.5")), now),
|
|
1247
|
+
Verdict::Allowed
|
|
1248
|
+
);
|
|
1249
|
+
assert!(matches!(
|
|
1250
|
+
limits.check_at("POST", "/registrations", &short, Some(ip("10.0.0.6")), now),
|
|
1251
|
+
Verdict::Limited { .. }
|
|
1252
|
+
));
|
|
868
1253
|
}
|
|
869
1254
|
|
|
870
1255
|
#[test]
|
|
871
1256
|
fn repeated_forwarded_header_lines_read_as_one_list() {
|
|
872
|
-
let limits = limits(&[
|
|
1257
|
+
let limits = limits(&[
|
|
1258
|
+
("HECKS_RATE_LIMIT_REGISTER", "1"),
|
|
1259
|
+
("HECKS_TRUSTED_PROXIES", "10.0.0.0/8"),
|
|
1260
|
+
]);
|
|
873
1261
|
let (peer, now) = (Some(ip("10.0.0.5")), Instant::now());
|
|
874
1262
|
let split = headers(&[("x-forwarded-for", "6.6.6.6"), ("x-forwarded-for", VISITOR)]);
|
|
875
1263
|
let joined = headers(&[("x-forwarded-for", &format!("9.9.9.9, {VISITOR}"))]);
|
|
876
|
-
assert_eq!(
|
|
877
|
-
|
|
1264
|
+
assert_eq!(
|
|
1265
|
+
limits.check_at("POST", "/registrations", &split, peer, now),
|
|
1266
|
+
Verdict::Allowed
|
|
1267
|
+
);
|
|
1268
|
+
assert!(matches!(
|
|
1269
|
+
limits.check_at("POST", "/registrations", &joined, peer, now),
|
|
1270
|
+
Verdict::Limited { .. }
|
|
1271
|
+
));
|
|
878
1272
|
}
|
|
879
1273
|
|
|
880
1274
|
#[tokio::test]
|
|
@@ -883,8 +1277,13 @@ mod tests {
|
|
|
883
1277
|
assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
|
|
884
1278
|
assert_eq!(response.headers()[RETRY_AFTER], "120");
|
|
885
1279
|
assert_eq!(response.headers()[CONTENT_TYPE], "application/json");
|
|
886
|
-
let bytes = axum::body::to_bytes(response.into_body(), 4096)
|
|
1280
|
+
let bytes = axum::body::to_bytes(response.into_body(), 4096)
|
|
1281
|
+
.await
|
|
1282
|
+
.unwrap();
|
|
887
1283
|
let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
|
888
|
-
assert_eq!(
|
|
1284
|
+
assert_eq!(
|
|
1285
|
+
body,
|
|
1286
|
+
json!({ "error": "too many requests, please try again later" })
|
|
1287
|
+
);
|
|
889
1288
|
}
|
|
890
1289
|
}
|