mostlyright-data 0.9.0__py3-none-any.whl
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.
- mostlyright/data_harness/__init__.py +158 -0
- mostlyright/data_harness/acquisition/__init__.py +55 -0
- mostlyright/data_harness/acquisition/http.py +2773 -0
- mostlyright/data_harness/acquisition/parsing.py +809 -0
- mostlyright/data_harness/acquisition/ranges.py +495 -0
- mostlyright/data_harness/acquisition/result_download.py +360 -0
- mostlyright/data_harness/acquisition/retention_admission.py +248 -0
- mostlyright/data_harness/acquisition/sandbox.py +4888 -0
- mostlyright/data_harness/acquisition/url_policy.py +530 -0
- mostlyright/data_harness/agent_runtime.py +2743 -0
- mostlyright/data_harness/assets/logo-ink.svg +31 -0
- mostlyright/data_harness/backends/__init__.py +28 -0
- mostlyright/data_harness/backends/pandas_backend.py +350 -0
- mostlyright/data_harness/backends/polars_backend.py +366 -0
- mostlyright/data_harness/backends/protocol.py +124 -0
- mostlyright/data_harness/backends/reference.py +83 -0
- mostlyright/data_harness/backends/registry.py +55 -0
- mostlyright/data_harness/backends/restrictions.py +126 -0
- mostlyright/data_harness/canonical.py +333 -0
- mostlyright/data_harness/catalog_job.py +625 -0
- mostlyright/data_harness/cli.py +5398 -0
- mostlyright/data_harness/contracts.py +53 -0
- mostlyright/data_harness/coordinator.py +1307 -0
- mostlyright/data_harness/deploy.py +924 -0
- mostlyright/data_harness/deploy_target.py +312 -0
- mostlyright/data_harness/deployment_evidence.py +1067 -0
- mostlyright/data_harness/event_presentation.py +576 -0
- mostlyright/data_harness/events.py +2152 -0
- mostlyright/data_harness/fast_delimited.py +239 -0
- mostlyright/data_harness/fleet.py +237 -0
- mostlyright/data_harness/formats.py +236 -0
- mostlyright/data_harness/governors.py +1163 -0
- mostlyright/data_harness/hosted_bootstrap.py +972 -0
- mostlyright/data_harness/hosted_crawler.py +1115 -0
- mostlyright/data_harness/hosted_crawler_container_smoke.py +351 -0
- mostlyright/data_harness/hosted_crawler_fetch.py +423 -0
- mostlyright/data_harness/hosted_crawler_job.py +1277 -0
- mostlyright/data_harness/hosted_crawler_protocol.py +676 -0
- mostlyright/data_harness/hosted_dataset.py +1500 -0
- mostlyright/data_harness/hosted_deploy.py +3037 -0
- mostlyright/data_harness/hosted_handoff.py +62 -0
- mostlyright/data_harness/hosted_ingestion_contract.py +504 -0
- mostlyright/data_harness/hosted_ingestion_job.py +356 -0
- mostlyright/data_harness/hosted_ingestion_job_smoke.py +40 -0
- mostlyright/data_harness/hosted_session_container_smoke.py +194 -0
- mostlyright/data_harness/hosted_session_worker.py +3554 -0
- mostlyright/data_harness/hosted_session_worker_job_smoke.py +46 -0
- mostlyright/data_harness/hosted_worker.py +6784 -0
- mostlyright/data_harness/ingestion/__init__.py +56 -0
- mostlyright/data_harness/ingestion/contracts.py +461 -0
- mostlyright/data_harness/ingestion/faults.py +42 -0
- mostlyright/data_harness/ingestion/gcs_store.py +1162 -0
- mostlyright/data_harness/ingestion/spool.py +130 -0
- mostlyright/data_harness/ingestion/store.py +885 -0
- mostlyright/data_harness/key_seam.py +434 -0
- mostlyright/data_harness/linux_process_boundary.py +262 -0
- mostlyright/data_harness/local_contracts.py +2880 -0
- mostlyright/data_harness/local_search/__init__.py +5 -0
- mostlyright/data_harness/local_search/build_index.py +1087 -0
- mostlyright/data_harness/local_search/contracts.py +920 -0
- mostlyright/data_harness/local_search/query_trace.py +266 -0
- mostlyright/data_harness/local_search/retrieval.py +700 -0
- mostlyright/data_harness/local_search/sealed.py +474 -0
- mostlyright/data_harness/local_search/service.py +784 -0
- mostlyright/data_harness/nbrender/CONTRACT.md +212 -0
- mostlyright/data_harness/nbrender/__init__.py +12 -0
- mostlyright/data_harness/nbrender/chrome.py +359 -0
- mostlyright/data_harness/nbrender/code_body.py +266 -0
- mostlyright/data_harness/nbrender/document.py +407 -0
- mostlyright/data_harness/nbrender/frame.py +275 -0
- mostlyright/data_harness/nbrender/interactive.py +337 -0
- mostlyright/data_harness/nbrender/markdown_body.py +477 -0
- mostlyright/data_harness/nbrender/mr_components.py +134 -0
- mostlyright/data_harness/nbrender/outputs_data.py +595 -0
- mostlyright/data_harness/nbrender/outputs_rich.py +906 -0
- mostlyright/data_harness/nbrender/outputs_source.py +260 -0
- mostlyright/data_harness/nbrender/outputs_stage.py +176 -0
- mostlyright/data_harness/nbrender/outputs_text.py +400 -0
- mostlyright/data_harness/nbrender/parse.py +394 -0
- mostlyright/data_harness/nbrender/status.py +40 -0
- mostlyright/data_harness/nbrender/tokens.py +1295 -0
- mostlyright/data_harness/notebook.py +1710 -0
- mostlyright/data_harness/offline.py +2049 -0
- mostlyright/data_harness/operation_registry.py +1007 -0
- mostlyright/data_harness/operator_setup.py +239 -0
- mostlyright/data_harness/pipeline.py +6428 -0
- mostlyright/data_harness/plan_graph.py +2026 -0
- mostlyright/data_harness/preparation/__init__.py +104 -0
- mostlyright/data_harness/preparation/contracts.py +1017 -0
- mostlyright/data_harness/preparation/engine.py +221 -0
- mostlyright/data_harness/preparation/errors.py +14 -0
- mostlyright/data_harness/preparation/gates.py +751 -0
- mostlyright/data_harness/preparation/joins.py +574 -0
- mostlyright/data_harness/preparation/profile.py +384 -0
- mostlyright/data_harness/preparation/table.py +217 -0
- mostlyright/data_harness/preparation/transforms.py +568 -0
- mostlyright/data_harness/progress_events.py +534 -0
- mostlyright/data_harness/readers/__init__.py +46 -0
- mostlyright/data_harness/readers/containers.py +963 -0
- mostlyright/data_harness/readers/contracts.py +542 -0
- mostlyright/data_harness/readers/delimited.py +257 -0
- mostlyright/data_harness/readers/grib2/__init__.py +33 -0
- mostlyright/data_harness/readers/grib2/admission.py +722 -0
- mostlyright/data_harness/readers/grib2/decode.py +1009 -0
- mostlyright/data_harness/readers/grib2/geometry.py +1133 -0
- mostlyright/data_harness/readers/grib2/portable_math.py +501 -0
- mostlyright/data_harness/readers/json_tabular.py +485 -0
- mostlyright/data_harness/readers/registry.py +514 -0
- mostlyright/data_harness/readers/samples/README.md +110 -0
- mostlyright/data_harness/readers/samples/archive.gzip/1.0.0/cities_one_stream/cities.csv.gz +0 -0
- mostlyright/data_harness/readers/samples/archive.gzip/1.0.0/cities_one_stream/expected.json +24 -0
- mostlyright/data_harness/readers/samples/archive.gzip/1.1.0/cities_one_stream/cities.csv.gz +0 -0
- mostlyright/data_harness/readers/samples/archive.gzip/1.1.0/cities_one_stream/expected.json +24 -0
- mostlyright/data_harness/readers/samples/archive.tar/1.0.0/cities_beside_a_directory_entry/cities.tar +0 -0
- mostlyright/data_harness/readers/samples/archive.tar/1.0.0/cities_beside_a_directory_entry/expected.json +24 -0
- mostlyright/data_harness/readers/samples/archive.tar/1.1.0/cities_beside_a_directory_entry/cities.tar +0 -0
- mostlyright/data_harness/readers/samples/archive.tar/1.1.0/cities_beside_a_directory_entry/expected.json +24 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.0.0/cities_beside_a_second_member/cities.zip +0 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.0.0/cities_beside_a_second_member/expected.json +25 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.1.0/dwd_semicolon_station_member/dwd-station.zip +0 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.1.0/dwd_semicolon_station_member/expected.json +25 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.2.0/dwd_semicolon_station_member/dwd-station.zip +0 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.2.0/dwd_semicolon_station_member/expected.json +25 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.0.0/an_ordinary_comma_separated_table/cities.csv +3 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.0.0/an_ordinary_comma_separated_table/expected.json +23 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.0.0/quoted_fields_holding_the_delimiter/cities.tsv +5 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.0.0/quoted_fields_holding_the_delimiter/expected.json +25 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.1.0/an_hourly_observation_table_served_as_plain_text/expected.json +30 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.1.0/an_hourly_observation_table_served_as_plain_text/observations.csv +5 -0
- mostlyright/data_harness/readers/samples/json.tabular/1.0.0/nested_hourly_observations/expected.json +44 -0
- mostlyright/data_harness/readers/samples/json.tabular/1.0.0/nested_hourly_observations/stations.json +1 -0
- mostlyright/data_harness/readers/samples/json.tabular/1.1.0/an_observation_stream_served_as_plain_text/expected.json +48 -0
- mostlyright/data_harness/readers/samples/json.tabular/1.1.0/an_observation_stream_served_as_plain_text/observations.ndjson +4 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/an_ordinary_table_beside_a_second_sheet/cities.xlsx +0 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/an_ordinary_table_beside_a_second_sheet/expected.json +24 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/shares_the_workbook_had_already_computed/expected.json +27 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/shares_the_workbook_had_already_computed/shares.xlsx +0 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.1.0/shares_the_workbook_had_already_computed/expected.json +27 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.1.0/shares_the_workbook_had_already_computed/shares.xlsx +0 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/README.md +20 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/gfs_2m_temperature/expected.json +55 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/gfs_2m_temperature/gfs-2m-temperature.grib2 +0 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_2m_temperature/expected.json +54 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_2m_temperature/hrrr-2m-temperature.grib2 +0 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_categorical_rain/expected.json +54 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_categorical_rain/hrrr-categorical-rain.grib2 +0 -0
- mostlyright/data_harness/readers/samples/weather.grib2/2.0.0/hrrr_2m_temperature/expected.json +54 -0
- mostlyright/data_harness/readers/samples/weather.grib2/2.0.0/hrrr_2m_temperature/hrrr-2m-temperature.grib2 +0 -0
- mostlyright/data_harness/readers/samples.py +582 -0
- mostlyright/data_harness/readers/spreadsheet.py +803 -0
- mostlyright/data_harness/readers/tabular.py +510 -0
- mostlyright/data_harness/recipe.py +5321 -0
- mostlyright/data_harness/repair/__init__.py +78 -0
- mostlyright/data_harness/repair/adapters.py +274 -0
- mostlyright/data_harness/repair/contracts.py +872 -0
- mostlyright/data_harness/repair/coordinator.py +1099 -0
- mostlyright/data_harness/repair/errors.py +16 -0
- mostlyright/data_harness/review.py +2533 -0
- mostlyright/data_harness/rowset.py +283 -0
- mostlyright/data_harness/serving.py +1975 -0
- mostlyright/data_harness/serving_edge.py +590 -0
- mostlyright/data_harness/serving_http.py +1031 -0
- mostlyright/data_harness/session_probes.py +759 -0
- mostlyright/data_harness/signing.py +101 -0
- mostlyright/data_harness/source_discovery.py +898 -0
- mostlyright/data_harness/sources/__init__.py +209 -0
- mostlyright/data_harness/sources/_adapter_steps.py +213 -0
- mostlyright/data_harness/sources/adapters.py +1214 -0
- mostlyright/data_harness/sources/cadence.py +1428 -0
- mostlyright/data_harness/sources/cadence_emission.py +453 -0
- mostlyright/data_harness/sources/cadence_history.py +546 -0
- mostlyright/data_harness/sources/catalog/__init__.py +17 -0
- mostlyright/data_harness/sources/catalog/admission.py +477 -0
- mostlyright/data_harness/sources/catalog/authoring.py +1701 -0
- mostlyright/data_harness/sources/catalog/authoring_policy.py +701 -0
- mostlyright/data_harness/sources/catalog/authoring_shards.py +1217 -0
- mostlyright/data_harness/sources/catalog/bounded_io.py +231 -0
- mostlyright/data_harness/sources/catalog/channel.py +523 -0
- mostlyright/data_harness/sources/catalog/channel_client.py +296 -0
- mostlyright/data_harness/sources/catalog/contracts.py +825 -0
- mostlyright/data_harness/sources/catalog/coverage.py +137 -0
- mostlyright/data_harness/sources/catalog/delta.py +1340 -0
- mostlyright/data_harness/sources/catalog/embedding.py +532 -0
- mostlyright/data_harness/sources/catalog/entry_v2.py +1182 -0
- mostlyright/data_harness/sources/catalog/fill.py +3889 -0
- mostlyright/data_harness/sources/catalog/fill_partitions.py +459 -0
- mostlyright/data_harness/sources/catalog/fill_staging.py +1105 -0
- mostlyright/data_harness/sources/catalog/gating.py +374 -0
- mostlyright/data_harness/sources/catalog/generation_receipt.py +1607 -0
- mostlyright/data_harness/sources/catalog/harvest/__init__.py +7 -0
- mostlyright/data_harness/sources/catalog/harvest/ckan.py +384 -0
- mostlyright/data_harness/sources/catalog/harvest/datagov_v4.py +798 -0
- mostlyright/data_harness/sources/catalog/harvest/protocol.py +964 -0
- mostlyright/data_harness/sources/catalog/harvest/sdmx.py +445 -0
- mostlyright/data_harness/sources/catalog/harvest/stac.py +384 -0
- mostlyright/data_harness/sources/catalog/health.py +447 -0
- mostlyright/data_harness/sources/catalog/hosted_catalog.py +105 -0
- mostlyright/data_harness/sources/catalog/identity_history.py +1549 -0
- mostlyright/data_harness/sources/catalog/neural.py +1618 -0
- mostlyright/data_harness/sources/catalog/packed_catalog.py +2345 -0
- mostlyright/data_harness/sources/catalog/packed_retrieval.py +1517 -0
- mostlyright/data_harness/sources/catalog/packed_writer.py +2802 -0
- mostlyright/data_harness/sources/catalog/query_trace.py +1037 -0
- mostlyright/data_harness/sources/catalog/recommend.py +171 -0
- mostlyright/data_harness/sources/catalog/retrieval.py +230 -0
- mostlyright/data_harness/sources/catalog/retrieval_manifest.py +995 -0
- mostlyright/data_harness/sources/catalog/rights_decisions.py +254 -0
- mostlyright/data_harness/sources/catalog/sealed.py +560 -0
- mostlyright/data_harness/sources/catalog/search.py +230 -0
- mostlyright/data_harness/sources/catalog/streaming_delta.py +1097 -0
- mostlyright/data_harness/sources/catalog/update.py +891 -0
- mostlyright/data_harness/sources/collections.py +815 -0
- mostlyright/data_harness/sources/contracts.py +2223 -0
- mostlyright/data_harness/sources/deletion.py +761 -0
- mostlyright/data_harness/sources/fitness.py +162 -0
- mostlyright/data_harness/sources/governance.py +163 -0
- mostlyright/data_harness/sources/hosted.py +173 -0
- mostlyright/data_harness/sources/integration.py +218 -0
- mostlyright/data_harness/sources/range_reader.py +418 -0
- mostlyright/data_harness/sources/registry.py +514 -0
- mostlyright/data_harness/sources/rights_rule.py +59 -0
- mostlyright/data_harness/sources/source_cadence_vectors.v1.json +1 -0
- mostlyright/data_harness/sources/sports.py +521 -0
- mostlyright/data_harness/sources/stream.py +524 -0
- mostlyright/data_harness/sources/stream_connector.py +418 -0
- mostlyright/data_harness/sources/stream_recorder.py +1404 -0
- mostlyright/data_harness/studio_boundary.py +2019 -0
- mostlyright/data_harness/thin/__init__.py +37 -0
- mostlyright/data_harness/thin/acquire.py +1137 -0
- mostlyright/data_harness/thin/acquire_cancel.py +579 -0
- mostlyright/data_harness/thin/approvals.py +617 -0
- mostlyright/data_harness/thin/commands.py +406 -0
- mostlyright/data_harness/thin/download.py +194 -0
- mostlyright/data_harness/thin/narrative.py +589 -0
- mostlyright/data_harness/thin/parity.py +1070 -0
- mostlyright/data_harness/thin/propose.py +2759 -0
- mostlyright/data_harness/thin/research.py +1663 -0
- mostlyright/data_harness/thin/router.py +924 -0
- mostlyright/data_harness/thin/runs.py +519 -0
- mostlyright/data_harness/thin/session.py +281 -0
- mostlyright/data_harness/thin/stream.py +501 -0
- mostlyright/data_harness/thin/transport.py +187 -0
- mostlyright/data_harness/thin/vocabulary.py +368 -0
- mostlyright/data_harness/thin/workers.py +164 -0
- mostlyright/data_harness/ucum/TABLE-PIN.json +40 -0
- mostlyright/data_harness/ucum/ucum-subset.v1.json +632 -0
- mostlyright/data_harness/unit_flow.py +927 -0
- mostlyright/data_harness/units.py +572 -0
- mostlyright/data_harness/ux/__init__.py +9 -0
- mostlyright/data_harness/ux/approve.py +485 -0
- mostlyright/data_harness/ux/author_yaml.py +597 -0
- mostlyright/data_harness/ux/cloud_auth.py +447 -0
- mostlyright/data_harness/ux/commands/__init__.py +260 -0
- mostlyright/data_harness/ux/commands/approve.py +136 -0
- mostlyright/data_harness/ux/commands/auth.py +744 -0
- mostlyright/data_harness/ux/commands/author.py +79 -0
- mostlyright/data_harness/ux/commands/catalog_author.py +403 -0
- mostlyright/data_harness/ux/commands/catalog_fill.py +523 -0
- mostlyright/data_harness/ux/commands/catalog_harvest.py +545 -0
- mostlyright/data_harness/ux/commands/catalog_publish.py +1838 -0
- mostlyright/data_harness/ux/commands/catalog_search.py +71 -0
- mostlyright/data_harness/ux/commands/catalog_update.py +437 -0
- mostlyright/data_harness/ux/commands/deploy.py +134 -0
- mostlyright/data_harness/ux/commands/deploy_dataset.py +98 -0
- mostlyright/data_harness/ux/commands/deploy_plan.py +105 -0
- mostlyright/data_harness/ux/commands/deploy_status.py +104 -0
- mostlyright/data_harness/ux/commands/diff.py +74 -0
- mostlyright/data_harness/ux/commands/index.py +84 -0
- mostlyright/data_harness/ux/commands/inventory.py +47 -0
- mostlyright/data_harness/ux/commands/list_builds.py +143 -0
- mostlyright/data_harness/ux/commands/login.py +63 -0
- mostlyright/data_harness/ux/commands/peek.py +236 -0
- mostlyright/data_harness/ux/commands/plan_check.py +90 -0
- mostlyright/data_harness/ux/commands/preflight.py +97 -0
- mostlyright/data_harness/ux/commands/record.py +107 -0
- mostlyright/data_harness/ux/commands/review_setup.py +47 -0
- mostlyright/data_harness/ux/commands/search.py +440 -0
- mostlyright/data_harness/ux/commands/show.py +61 -0
- mostlyright/data_harness/ux/commands/whoami.py +37 -0
- mostlyright/data_harness/ux/credential_native.py +551 -0
- mostlyright/data_harness/ux/credential_store.py +1055 -0
- mostlyright/data_harness/ux/credentials.py +631 -0
- mostlyright/data_harness/ux/diffing.py +444 -0
- mostlyright/data_harness/ux/headline.py +671 -0
- mostlyright/data_harness/ux/hosted_acquisition.py +974 -0
- mostlyright/data_harness/ux/hosted_run_status.py +619 -0
- mostlyright/data_harness/ux/inventory.py +427 -0
- mostlyright/data_harness/ux/local_review.py +375 -0
- mostlyright/data_harness/ux/login.py +691 -0
- mostlyright/data_harness/ux/path_kind.py +147 -0
- mostlyright/data_harness/ux/peek.py +1000 -0
- mostlyright/data_harness/ux/plain_file.py +178 -0
- mostlyright/data_harness/ux/plan_check.py +311 -0
- mostlyright/data_harness/ux/preflight.py +918 -0
- mostlyright/data_harness/ux/readers.py +1124 -0
- mostlyright/data_harness/ux/remediation.py +2195 -0
- mostlyright/data_harness/ux/render.py +657 -0
- mostlyright/data_harness/ux/workload.py +1077 -0
- mostlyright/data_harness/viewer.py +3713 -0
- mostlyright/data_harness/visual_run/__init__.py +83 -0
- mostlyright/data_harness/visual_run/authoring.py +235 -0
- mostlyright/data_harness/visual_run/contracts.py +673 -0
- mostlyright/data_harness/visual_run/materialize.py +486 -0
- mostlyright/data_harness/visual_run/observations.py +874 -0
- mostlyright/data_harness/visual_run/query.py +259 -0
- mostlyright/data_harness/visual_run/reducer.py +280 -0
- mostlyright/data_harness/visual_run/sdk.py +892 -0
- mostlyright/data_harness/visual_run/store.py +584 -0
- mostlyright/data_harness/visual_run/transport.py +239 -0
- mostlyright/data_harness/watch.py +2999 -0
- mostlyright_data-0.9.0.dist-info/METADATA +607 -0
- mostlyright_data-0.9.0.dist-info/RECORD +314 -0
- mostlyright_data-0.9.0.dist-info/WHEEL +4 -0
- mostlyright_data-0.9.0.dist-info/entry_points.txt +12 -0
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
"""Fail-closed public HTTPS target validation and DNS pinning."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ipaddress
|
|
6
|
+
import re
|
|
7
|
+
import socket
|
|
8
|
+
import unicodedata
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Protocol
|
|
11
|
+
from urllib.parse import SplitResult, urlsplit, urlunsplit
|
|
12
|
+
|
|
13
|
+
import idna
|
|
14
|
+
|
|
15
|
+
from mostlyright.data_harness.canonical import canonical_sha256
|
|
16
|
+
|
|
17
|
+
MAX_URL_LENGTH = 2_048
|
|
18
|
+
MAX_DNS_ANSWERS = 32
|
|
19
|
+
DEFAULT_ALLOWED_PORTS = frozenset({443})
|
|
20
|
+
|
|
21
|
+
_METADATA_HOSTS = frozenset(
|
|
22
|
+
{
|
|
23
|
+
"metadata",
|
|
24
|
+
"metadata.google.internal",
|
|
25
|
+
"metadata.goog",
|
|
26
|
+
"instance-data",
|
|
27
|
+
"169.254.169.254",
|
|
28
|
+
"100.100.100.200",
|
|
29
|
+
}
|
|
30
|
+
)
|
|
31
|
+
_FORBIDDEN_NETWORKS = tuple(
|
|
32
|
+
ipaddress.ip_network(value)
|
|
33
|
+
for value in (
|
|
34
|
+
"0.0.0.0/8",
|
|
35
|
+
"10.0.0.0/8",
|
|
36
|
+
"100.64.0.0/10",
|
|
37
|
+
"127.0.0.0/8",
|
|
38
|
+
"169.254.0.0/16",
|
|
39
|
+
"172.16.0.0/12",
|
|
40
|
+
"192.0.0.0/24",
|
|
41
|
+
"192.0.2.0/24",
|
|
42
|
+
"192.168.0.0/16",
|
|
43
|
+
"198.18.0.0/15",
|
|
44
|
+
"198.51.100.0/24",
|
|
45
|
+
"203.0.113.0/24",
|
|
46
|
+
"224.0.0.0/4",
|
|
47
|
+
"240.0.0.0/4",
|
|
48
|
+
"::/128",
|
|
49
|
+
"::1/128",
|
|
50
|
+
"::ffff:0:0/96",
|
|
51
|
+
"64:ff9b::/96",
|
|
52
|
+
"100::/64",
|
|
53
|
+
"2001:db8::/32",
|
|
54
|
+
"fc00::/7",
|
|
55
|
+
"fe80::/10",
|
|
56
|
+
"ff00::/8",
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
# The URL Standard's *forbidden domain code points*: the forbidden host code points, plus the C0
|
|
60
|
+
# controls, plus U+0025. A host parser rejects a domain holding one before it reads anything else,
|
|
61
|
+
# so no URL parser keeps any of them inside a machine name.
|
|
62
|
+
#
|
|
63
|
+
# Spelled out rather than probed, for the same reason `_SPECIAL_SCHEMES` is: the list is normative
|
|
64
|
+
# and closed, and `urlsplit` -- the only parser available to probe locally -- is a generic RFC 3986
|
|
65
|
+
# splitter that keeps most of them. Measured against node v24.13.0 over every code point the
|
|
66
|
+
# catalog scanner's reader-terminator class matches, this agrees with the parser on all 33.
|
|
67
|
+
_FORBIDDEN_DOMAIN_CODE_POINTS = frozenset(
|
|
68
|
+
"".join(chr(code) for code in range(0x20)) + " #%/:<>?@[\\]^|"
|
|
69
|
+
)
|
|
70
|
+
_DECIMAL_OR_OBFUSCATED_IP = re.compile(
|
|
71
|
+
r"^(?:0[xX][0-9A-Fa-f]+|0[0-7]+|[0-9]+)(?:\.(?:0[xX][0-9A-Fa-f]+|0[0-7]+|[0-9]+)){0,3}$"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class AcquisitionSecurityError(ValueError):
|
|
76
|
+
"""Stable acquisition policy failure."""
|
|
77
|
+
|
|
78
|
+
def __init__(self, code: str, detail: str) -> None:
|
|
79
|
+
self.code = code
|
|
80
|
+
self.detail = detail
|
|
81
|
+
super().__init__(f"{detail} [{code}]")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Resolver(Protocol):
|
|
85
|
+
def resolve(self, hostname: str, port: int) -> tuple[str, ...]:
|
|
86
|
+
"""Return every address candidate that may be used for this connection."""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class SystemResolver:
|
|
91
|
+
"""System DNS resolver with a bounded, deduplicated answer set."""
|
|
92
|
+
|
|
93
|
+
def resolve(self, hostname: str, port: int) -> tuple[str, ...]:
|
|
94
|
+
try:
|
|
95
|
+
results = socket.getaddrinfo(
|
|
96
|
+
hostname,
|
|
97
|
+
port,
|
|
98
|
+
family=socket.AF_UNSPEC,
|
|
99
|
+
type=socket.SOCK_STREAM,
|
|
100
|
+
proto=socket.IPPROTO_TCP,
|
|
101
|
+
)
|
|
102
|
+
except socket.gaierror as exc:
|
|
103
|
+
raise AcquisitionSecurityError(
|
|
104
|
+
"DNS_FAILURE",
|
|
105
|
+
f"public host resolution failed: {exc.errno}",
|
|
106
|
+
) from None
|
|
107
|
+
addresses = tuple(dict.fromkeys(item[4][0] for item in results))
|
|
108
|
+
if not addresses:
|
|
109
|
+
raise AcquisitionSecurityError("DNS_EMPTY", "public host returned no addresses")
|
|
110
|
+
if len(addresses) > MAX_DNS_ANSWERS:
|
|
111
|
+
raise AcquisitionSecurityError(
|
|
112
|
+
"DNS_ANSWER_LIMIT",
|
|
113
|
+
f"public host returned more than {MAX_DNS_ANSWERS} addresses",
|
|
114
|
+
)
|
|
115
|
+
return addresses
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True)
|
|
119
|
+
class EgressPolicy:
|
|
120
|
+
"""Coordinator-owned public-host allowlist."""
|
|
121
|
+
|
|
122
|
+
allowed_hostnames: tuple[str, ...]
|
|
123
|
+
allowed_suffixes: tuple[str, ...] = ()
|
|
124
|
+
allowed_ports: frozenset[int] = DEFAULT_ALLOWED_PORTS
|
|
125
|
+
|
|
126
|
+
def __post_init__(self) -> None:
|
|
127
|
+
if not self.allowed_hostnames and not self.allowed_suffixes:
|
|
128
|
+
raise AcquisitionSecurityError(
|
|
129
|
+
"EGRESS_ALLOWLIST_EMPTY",
|
|
130
|
+
"at least one exact hostname or suffix is required",
|
|
131
|
+
)
|
|
132
|
+
for value in (*self.allowed_hostnames, *self.allowed_suffixes):
|
|
133
|
+
normalized = _normalize_hostname(value.lstrip("."))
|
|
134
|
+
if normalized != value.lstrip(".").rstrip(".").lower():
|
|
135
|
+
raise AcquisitionSecurityError(
|
|
136
|
+
"EGRESS_HOST_CANONICAL",
|
|
137
|
+
"egress host entries must use canonical ASCII spelling",
|
|
138
|
+
)
|
|
139
|
+
if not self.allowed_ports or any(
|
|
140
|
+
type(port) is not int or port < 1 or port > 65_535 for port in self.allowed_ports
|
|
141
|
+
):
|
|
142
|
+
raise AcquisitionSecurityError(
|
|
143
|
+
"EGRESS_PORTS",
|
|
144
|
+
"allowed ports must be a non-empty set of valid integers",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def permits(self, hostname: str, port: int) -> bool:
|
|
148
|
+
if port not in self.allowed_ports:
|
|
149
|
+
return False
|
|
150
|
+
if hostname in self.allowed_hostnames:
|
|
151
|
+
return True
|
|
152
|
+
return any(
|
|
153
|
+
hostname != suffix and hostname.endswith("." + suffix)
|
|
154
|
+
for suffix in self.allowed_suffixes
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass(frozen=True)
|
|
159
|
+
class ValidatedTarget:
|
|
160
|
+
"""One URL bound to an exact DNS-validation epoch."""
|
|
161
|
+
|
|
162
|
+
original_url: str
|
|
163
|
+
normalized_url: str
|
|
164
|
+
hostname: str
|
|
165
|
+
port: int
|
|
166
|
+
approved_ips: tuple[str, ...]
|
|
167
|
+
resolution_digest: str
|
|
168
|
+
|
|
169
|
+
def __post_init__(self) -> None:
|
|
170
|
+
if not self.approved_ips:
|
|
171
|
+
raise AcquisitionSecurityError("DNS_EMPTY", "target has no approved peer addresses")
|
|
172
|
+
expected = canonical_sha256(
|
|
173
|
+
{
|
|
174
|
+
"hostname": self.hostname,
|
|
175
|
+
"port": self.port,
|
|
176
|
+
"approved_ips": list(self.approved_ips),
|
|
177
|
+
}
|
|
178
|
+
)
|
|
179
|
+
if expected != self.resolution_digest:
|
|
180
|
+
raise AcquisitionSecurityError(
|
|
181
|
+
"RESOLUTION_DIGEST",
|
|
182
|
+
"target resolution digest does not match its approved peer set",
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def request_target(self) -> str:
|
|
187
|
+
parsed = urlsplit(self.normalized_url)
|
|
188
|
+
return urlunsplit(("", "", parsed.path or "/", parsed.query, ""))
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def validate_public_https_url(
|
|
192
|
+
url: str,
|
|
193
|
+
*,
|
|
194
|
+
resolver: Resolver,
|
|
195
|
+
egress_policy: EgressPolicy,
|
|
196
|
+
) -> ValidatedTarget:
|
|
197
|
+
"""Validate a public HTTPS URL and bind every DNS answer to the result."""
|
|
198
|
+
|
|
199
|
+
if not isinstance(url, str) or not url or len(url) > MAX_URL_LENGTH:
|
|
200
|
+
raise AcquisitionSecurityError(
|
|
201
|
+
"URL_LENGTH",
|
|
202
|
+
f"URL must contain 1..{MAX_URL_LENGTH} characters",
|
|
203
|
+
)
|
|
204
|
+
if any(0xD800 <= ord(char) <= 0xDFFF for char in url):
|
|
205
|
+
raise AcquisitionSecurityError("URL_UNICODE", "URL contains a non-Unicode-scalar value")
|
|
206
|
+
if any(ord(char) < 0x20 or ord(char) == 0x7F for char in url):
|
|
207
|
+
raise AcquisitionSecurityError("URL_CONTROL", "URL contains a control character")
|
|
208
|
+
try:
|
|
209
|
+
parsed = urlsplit(url)
|
|
210
|
+
port = parsed.port
|
|
211
|
+
except ValueError:
|
|
212
|
+
raise AcquisitionSecurityError("URL_PORT", "URL has an invalid port") from None
|
|
213
|
+
_validate_url_structure(parsed)
|
|
214
|
+
hostname = canonical_public_hostname(parsed)
|
|
215
|
+
effective_port = port if port is not None else 443
|
|
216
|
+
if not egress_policy.permits(hostname, effective_port):
|
|
217
|
+
raise AcquisitionSecurityError(
|
|
218
|
+
"EGRESS_DENIED",
|
|
219
|
+
"target hostname or port is not coordinator-allowlisted",
|
|
220
|
+
)
|
|
221
|
+
if hostname in _METADATA_HOSTS or hostname.endswith(".internal"):
|
|
222
|
+
raise AcquisitionSecurityError("METADATA_DESTINATION", "metadata/internal hosts are denied")
|
|
223
|
+
|
|
224
|
+
literal = _parse_ip_literal(hostname)
|
|
225
|
+
if literal is not None:
|
|
226
|
+
addresses = (literal.compressed,)
|
|
227
|
+
else:
|
|
228
|
+
if _looks_like_obfuscated_ip(hostname):
|
|
229
|
+
raise AcquisitionSecurityError(
|
|
230
|
+
"OBFUSCATED_IP",
|
|
231
|
+
"non-canonical numeric IP hostnames are denied",
|
|
232
|
+
)
|
|
233
|
+
addresses = resolver.resolve(hostname, effective_port)
|
|
234
|
+
approved: list[str] = []
|
|
235
|
+
for raw in addresses:
|
|
236
|
+
try:
|
|
237
|
+
address = ipaddress.ip_address(raw)
|
|
238
|
+
except ValueError:
|
|
239
|
+
raise AcquisitionSecurityError(
|
|
240
|
+
"DNS_ADDRESS",
|
|
241
|
+
"resolver returned a non-IP address",
|
|
242
|
+
) from None
|
|
243
|
+
_require_globally_routable(address)
|
|
244
|
+
approved.append(address.compressed)
|
|
245
|
+
approved_ips = tuple(sorted(dict.fromkeys(approved)))
|
|
246
|
+
if not approved_ips:
|
|
247
|
+
raise AcquisitionSecurityError("DNS_EMPTY", "target has no approved public addresses")
|
|
248
|
+
if len(approved_ips) > MAX_DNS_ANSWERS:
|
|
249
|
+
raise AcquisitionSecurityError(
|
|
250
|
+
"DNS_ANSWER_LIMIT",
|
|
251
|
+
f"target has more than {MAX_DNS_ANSWERS} approved addresses",
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
netloc = hostname
|
|
255
|
+
if ":" in hostname and literal is not None:
|
|
256
|
+
netloc = f"[{hostname}]"
|
|
257
|
+
if effective_port != 443:
|
|
258
|
+
netloc = f"{netloc}:{effective_port}"
|
|
259
|
+
normalized_url = urlunsplit(
|
|
260
|
+
(
|
|
261
|
+
"https",
|
|
262
|
+
netloc,
|
|
263
|
+
parsed.path or "/",
|
|
264
|
+
parsed.query,
|
|
265
|
+
"",
|
|
266
|
+
)
|
|
267
|
+
)
|
|
268
|
+
resolution_digest = canonical_sha256(
|
|
269
|
+
{
|
|
270
|
+
"hostname": hostname,
|
|
271
|
+
"port": effective_port,
|
|
272
|
+
"approved_ips": list(approved_ips),
|
|
273
|
+
}
|
|
274
|
+
)
|
|
275
|
+
return ValidatedTarget(
|
|
276
|
+
original_url=url,
|
|
277
|
+
normalized_url=normalized_url,
|
|
278
|
+
hostname=hostname,
|
|
279
|
+
port=effective_port,
|
|
280
|
+
approved_ips=approved_ips,
|
|
281
|
+
resolution_digest=resolution_digest,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def require_approved_peer(target: ValidatedTarget, connected_peer: str) -> str:
|
|
286
|
+
"""Verify the actual connected peer belongs to the exact validated epoch."""
|
|
287
|
+
|
|
288
|
+
try:
|
|
289
|
+
peer = ipaddress.ip_address(connected_peer).compressed
|
|
290
|
+
except ValueError:
|
|
291
|
+
raise AcquisitionSecurityError(
|
|
292
|
+
"PEER_ADDRESS",
|
|
293
|
+
"transport returned a non-IP peer address",
|
|
294
|
+
) from None
|
|
295
|
+
if peer not in target.approved_ips:
|
|
296
|
+
raise AcquisitionSecurityError(
|
|
297
|
+
"PEER_MISMATCH",
|
|
298
|
+
"connected peer was not part of the approved DNS result",
|
|
299
|
+
)
|
|
300
|
+
return peer
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def canonical_public_hostname(parsed: SplitResult) -> str:
|
|
304
|
+
"""The one canonical ASCII machine name ``parsed`` names, or a typed refusal.
|
|
305
|
+
|
|
306
|
+
This is the repository's single answer to "which host is this string about", and it is a
|
|
307
|
+
function rather than three checks written out at each call site because it has to be *one*
|
|
308
|
+
answer. ``urlsplit`` is not that answer on its own: it is a generic RFC 3986 splitter, and the
|
|
309
|
+
parsers that actually resolve these strings -- every browser, every JavaScript consumer, every
|
|
310
|
+
modern HTTP client -- implement the WHATWG URL Standard instead. Where the two disagree,
|
|
311
|
+
``urlsplit`` hands back a *string* that no parser would resolve as a machine name, and a rule
|
|
312
|
+
that consults an allowlist about that string is asking about something that is not a host.
|
|
313
|
+
|
|
314
|
+
Four refusals, in this order, and each one is a place the two readings diverge:
|
|
315
|
+
|
|
316
|
+
* a port ``urlsplit`` will not read. ``.hostname`` is computed by splitting everything after
|
|
317
|
+
the last ``:`` off the netloc and never looking at it, so
|
|
318
|
+
``https://allowed.example:S3cretToken/x`` answers ``allowed.example`` while ``parsed.port``
|
|
319
|
+
raises. Whatever is written where a port goes reaches the caller's artifact verbatim -- a
|
|
320
|
+
literal credential, a private machine name -- and no rule downstream of ``.hostname`` can
|
|
321
|
+
see it. ``validate_public_https_url`` was safe only because it happens to evaluate
|
|
322
|
+
``parsed.port`` inside a ``try`` of its own before it gets here; the catalog door does not
|
|
323
|
+
call that function, so the shared answer has to carry the question rather than each caller
|
|
324
|
+
duplicating it. Asking here applies the rule to every caller.
|
|
325
|
+
* a backslash anywhere in the authority. ``urlsplit`` keeps it; for a special scheme the URL
|
|
326
|
+
Standard treats ``\\`` exactly as ``/``, so the authority *ends* there. The whole run
|
|
327
|
+
``a.private.example\\allowed.example`` is one host to ``urlsplit`` and the host
|
|
328
|
+
``a.private.example`` to everything that resolves it.
|
|
329
|
+
* a percent sign in the host. ``urlsplit`` keeps it; a WHATWG parser refuses the URL outright.
|
|
330
|
+
* anything ``_normalize_hostname`` cannot read as a hostname: the URL Standard's *domain to
|
|
331
|
+
ASCII* (:func:`_domain_to_ascii`), then per-label ``[a-z0-9-]``, at most 63 characters per
|
|
332
|
+
label and 253 overall, with IP literals passed through for the caller to judge.
|
|
333
|
+
|
|
334
|
+
The return value is the *normalised* name, and callers must consult their allowlist about that
|
|
335
|
+
and not about what they were handed. Normalisation is not decoration here: it is what makes the
|
|
336
|
+
name this repository checks the same name a browser resolves, so ``a.example<U+00AD>b.example``
|
|
337
|
+
is checked as the single name a browser goes to rather than as two names glued by a character
|
|
338
|
+
nobody can see.
|
|
339
|
+
|
|
340
|
+
And normalisation is not a licence to *invent* a name. A spelling no URL parser can read is
|
|
341
|
+
refused rather than reduced to whatever a lenient codec leaves behind:
|
|
342
|
+
``a.private.example<U+200D>allowed.example`` is not a machine name, ``new URL()`` throws on it,
|
|
343
|
+
and answering ``a.private.exampleallowed.example`` -- a name under an allowlisted suffix --
|
|
344
|
+
would be this function inventing the very thing it exists to prevent. Python's ``idna`` codec
|
|
345
|
+
deletes that joiner, so this function instead delegates to :func:`_domain_to_ascii`, which
|
|
346
|
+
applies the standard's derived code-point classes rather than an incident-driven list.
|
|
347
|
+
"""
|
|
348
|
+
|
|
349
|
+
try:
|
|
350
|
+
_ = parsed.port
|
|
351
|
+
except ValueError:
|
|
352
|
+
raise AcquisitionSecurityError("URL_PORT", "URL has an invalid port") from None
|
|
353
|
+
if "\\" in parsed.netloc:
|
|
354
|
+
raise AcquisitionSecurityError("URL_BACKSLASH", "URL authority backslashes are forbidden")
|
|
355
|
+
hostname = parsed.hostname or ""
|
|
356
|
+
if "%" in hostname:
|
|
357
|
+
raise AcquisitionSecurityError(
|
|
358
|
+
"URL_HOST_ENCODING",
|
|
359
|
+
"percent-encoded hostnames are forbidden",
|
|
360
|
+
)
|
|
361
|
+
return _normalize_hostname(hostname)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _validate_url_structure(parsed: SplitResult) -> None:
|
|
365
|
+
if parsed.scheme != "https":
|
|
366
|
+
raise AcquisitionSecurityError("URL_SCHEME", "only HTTPS URLs are allowed")
|
|
367
|
+
if not parsed.netloc or parsed.hostname is None:
|
|
368
|
+
raise AcquisitionSecurityError("URL_HOST", "URL must contain a hostname")
|
|
369
|
+
if parsed.username is not None or parsed.password is not None:
|
|
370
|
+
raise AcquisitionSecurityError("URL_USERINFO", "URL user information is forbidden")
|
|
371
|
+
if parsed.fragment:
|
|
372
|
+
raise AcquisitionSecurityError("URL_FRAGMENT", "URL fragments are forbidden")
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _domain_to_ascii(raw: str) -> str:
|
|
376
|
+
"""``raw`` as the URL Standard's *domain to ASCII* reads it, or a typed refusal.
|
|
377
|
+
|
|
378
|
+
Python's built-in ``idna`` **codec** is not this operation and using it here failed open. It
|
|
379
|
+
implements IDNA2003 nameprep, whose "commonly mapped to nothing" table silently *deletes* code
|
|
380
|
+
points that the URL Standard's UTS46 processing refuses outright. The two answers differ in the
|
|
381
|
+
direction that leaks: the codec deletes the invisible character, the glued name ends in an
|
|
382
|
+
allowlisted suffix, the allowlist says public, and the private machine name is written into the
|
|
383
|
+
shared catalog verbatim -- while no URL parser can read the string at all. Swept over the glue
|
|
384
|
+
code points this module was attacked with, node v24.13.0 throws ``Invalid URL`` on
|
|
385
|
+
``https://internal-wiki.corp.example.com<GLUE>bulks-faostat.fao.org/x`` for U+200C, U+200D,
|
|
386
|
+
U+200E, U+200F, U+061C, U+E0000 and U+E0001 through U+E0004, every one of which the codec
|
|
387
|
+
reduced to a name under ``fao.org``.
|
|
388
|
+
|
|
389
|
+
A name no parser can read is a refusal and never a reduction; that is what
|
|
390
|
+
:func:`canonical_public_hostname` promises its callers. The question is therefore asked of a
|
|
391
|
+
UTS46 implementation with the standard's own validity checks
|
|
392
|
+
(non-transitional processing, ``UseSTD3ASCIIRules=false``, and the joiner and code-point
|
|
393
|
+
classes UTS46 derives from the Unicode tables) rather than of a list of the code points that
|
|
394
|
+
happened to be found.
|
|
395
|
+
|
|
396
|
+
**That was measured over 33 hand-picked code points and the promise is about all of them.**
|
|
397
|
+
Swept instead over the whole code-point space, the pinned ``idna`` table admitted three names
|
|
398
|
+
node v24.13.0 refuses outright -- U+A7D2, U+A7D4 and U+A7F1, the last of which UTS46 *maps* to
|
|
399
|
+
``s`` -- each of them reducing ``internal-wiki.corp.example.com<GLUE>bulks-faostat.fao.org`` to
|
|
400
|
+
a name under an allowlisted suffix, which is the exact failure this function exists to stop.
|
|
401
|
+
The three are not the point and are not fixed as three. They are all *unassigned* in this
|
|
402
|
+
interpreter's Unicode data and assigned in the newer Unicode the installed ``idna`` was built
|
|
403
|
+
from, and UTS46 marks an unassigned code point ``disallowed``. So the refusal below is that
|
|
404
|
+
rule, asked of :mod:`unicodedata` -- the same source the catalog scanner asks for NFKC -- and
|
|
405
|
+
it holds for code points added to the table ahead of the interpreter rather than for
|
|
406
|
+
the three that happened to exist on the day. It costs refusals for the 38 further unassigned
|
|
407
|
+
code points node currently reads; none of them is a name the public DNS resolves, and refusing
|
|
408
|
+
a name whose spelling this interpreter cannot classify is the direction this module refuses in.
|
|
409
|
+
|
|
410
|
+
The residual set is still governed by a table (``idna``) pinned independently of the ICU in
|
|
411
|
+
every consumer that will read ``catalog.json``, so
|
|
412
|
+
``tests/h3/test_url_policy.py::test_the_whole_code_point_space_is_pinned_against_the_installed_table``
|
|
413
|
+
sweeps the entire space and pins the answer, and a dependency bump that moves it fails there
|
|
414
|
+
instead of moving silently.
|
|
415
|
+
|
|
416
|
+
A pure-ASCII name is its own domain-to-ASCII form apart from case, so it takes the same path it
|
|
417
|
+
always did and the hostname grammar in :func:`_normalize_hostname` judges it. UTS46 changes and
|
|
418
|
+
deletes nothing in ASCII once STD3 rules are off, so this short-circuit is the standard's
|
|
419
|
+
answer and not an exemption from it -- and it is what keeps IP literals, and the underscore and
|
|
420
|
+
hyphen shapes this module already states as false refusals, exactly where they were.
|
|
421
|
+
"""
|
|
422
|
+
|
|
423
|
+
if raw.isascii():
|
|
424
|
+
return raw.lower()
|
|
425
|
+
if any(unicodedata.category(character) == "Cn" for character in raw):
|
|
426
|
+
raise AcquisitionSecurityError(
|
|
427
|
+
"URL_HOST",
|
|
428
|
+
"hostname is spelled with a code point this Unicode data does not assign",
|
|
429
|
+
)
|
|
430
|
+
try:
|
|
431
|
+
encoded = idna.encode(raw, uts46=True, std3_rules=False, transitional=False)
|
|
432
|
+
except UnicodeError:
|
|
433
|
+
raise AcquisitionSecurityError(
|
|
434
|
+
"URL_HOST",
|
|
435
|
+
"hostname is not a domain a URL parser can read",
|
|
436
|
+
) from None
|
|
437
|
+
return encoded.decode("ascii").lower()
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def a_domain_may_contain(character: str) -> bool:
|
|
441
|
+
"""Whether a URL parser goes on reading a machine name when it meets ``character``.
|
|
442
|
+
|
|
443
|
+
This is the URL Standard's *forbidden domain code point* set and nothing else. That is the
|
|
444
|
+
whole of the standard's character-level answer: the host parser accumulates code points until
|
|
445
|
+
it meets one of those, and only then runs *domain to ASCII* over the **whole name** it
|
|
446
|
+
collected. Validity is a property of the name, never of a character, and the two questions
|
|
447
|
+
are answered in that order by every parser that will read ``catalog.json``.
|
|
448
|
+
|
|
449
|
+
It exists because reader's punctuation and a parser's alphabet are different sets and the
|
|
450
|
+
catalog scanner had been using one for the other. ``"`` and ``'`` end a link for anyone reading
|
|
451
|
+
a sentence; to every URL parser they are ordinary host characters, so
|
|
452
|
+
``https://allowed.example"internal.corp/x`` names ``allowed.example"internal.corp`` and
|
|
453
|
+
resolves. A space does not: it is a forbidden domain code point, and the same string written
|
|
454
|
+
with one is refused by every parser. A rule that treats those two the same way is wrong about
|
|
455
|
+
one of them, and which one it is wrong about decides whether the mistake leaks.
|
|
456
|
+
|
|
457
|
+
This function intentionally does not run ``_domain_to_ascii(f"a{character}b")``. Domain-to-
|
|
458
|
+
ASCII is a whole-name operation, while both callers use ``False`` to **stop looking**: the
|
|
459
|
+
catalog scanner reads it as "the machine name ends here", and where the name has not started
|
|
460
|
+
yet that means "this is prose, report no link at all". A per-character domain-to-ASCII probe
|
|
461
|
+
diverges from consumers' ICU tables by construction, and every divergence points at silence.
|
|
462
|
+
Measured over the whole code-point space, such a probe rejects 974,396 code points that are not
|
|
463
|
+
forbidden domain code points, while node v24.13.0 reads a machine name from
|
|
464
|
+
``https:private.example<C>/x`` for **16,604** of them -- U+00A9 and U+00B0 among them -- so a
|
|
465
|
+
private host and literal credential could otherwise enter the shared catalog with no link
|
|
466
|
+
reported at all.
|
|
467
|
+
|
|
468
|
+
So this answers the alphabet question, exactly, and callers that need to know whether the name
|
|
469
|
+
is one this repository will admit must ask for the name. :func:`canonical_public_hostname` is
|
|
470
|
+
that question and it refuses what it cannot read, which is the direction a shared artifact can
|
|
471
|
+
afford to be wrong in. An underscore is kept inside a machine name by every parser and refused
|
|
472
|
+
by :func:`_normalize_hostname`; that stays a stated false refusal and it stays over there.
|
|
473
|
+
|
|
474
|
+
``:`` is answered ``False`` here because it is a forbidden domain code point; an authority may
|
|
475
|
+
still carry one, introducing the port, which is a fact about authorities rather than about
|
|
476
|
+
domains and belongs to the caller that is looking at an authority.
|
|
477
|
+
"""
|
|
478
|
+
|
|
479
|
+
return character not in _FORBIDDEN_DOMAIN_CODE_POINTS
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _normalize_hostname(hostname: str) -> str:
|
|
483
|
+
raw = hostname.rstrip(".")
|
|
484
|
+
if not raw or len(raw) > 253:
|
|
485
|
+
raise AcquisitionSecurityError("URL_HOST", "hostname length is invalid")
|
|
486
|
+
normalized = _domain_to_ascii(raw)
|
|
487
|
+
labels = normalized.split(".")
|
|
488
|
+
if any(
|
|
489
|
+
not label
|
|
490
|
+
or len(label) > 63
|
|
491
|
+
or label.startswith("-")
|
|
492
|
+
or label.endswith("-")
|
|
493
|
+
or not re.fullmatch(r"[a-z0-9-]+", label)
|
|
494
|
+
for label in labels
|
|
495
|
+
):
|
|
496
|
+
if _parse_ip_literal(normalized) is None:
|
|
497
|
+
raise AcquisitionSecurityError("URL_HOST", "hostname labels are invalid")
|
|
498
|
+
return normalized
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _parse_ip_literal(hostname: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
|
|
502
|
+
try:
|
|
503
|
+
return ipaddress.ip_address(hostname)
|
|
504
|
+
except ValueError:
|
|
505
|
+
return None
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _looks_like_obfuscated_ip(hostname: str) -> bool:
|
|
509
|
+
if _DECIMAL_OR_OBFUSCATED_IP.fullmatch(hostname):
|
|
510
|
+
return _parse_ip_literal(hostname) is None
|
|
511
|
+
return False
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _require_globally_routable(
|
|
515
|
+
address: ipaddress.IPv4Address | ipaddress.IPv6Address,
|
|
516
|
+
) -> None:
|
|
517
|
+
if (
|
|
518
|
+
not address.is_global
|
|
519
|
+
or address.is_private
|
|
520
|
+
or address.is_loopback
|
|
521
|
+
or address.is_link_local
|
|
522
|
+
or address.is_multicast
|
|
523
|
+
or address.is_reserved
|
|
524
|
+
or address.is_unspecified
|
|
525
|
+
or any(address in network for network in _FORBIDDEN_NETWORKS)
|
|
526
|
+
):
|
|
527
|
+
raise AcquisitionSecurityError(
|
|
528
|
+
"NON_PUBLIC_DESTINATION",
|
|
529
|
+
"loopback/private/link-local/metadata/reserved destinations are denied",
|
|
530
|
+
)
|