diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..24e115f --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.patch -whitespace diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index b817806..1f5e0fe 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -27,6 +27,6 @@ jobs: - name: Validate metadata, patches, and process manager run: pytest -q - name: Build amd64 image - run: docker build --build-arg BUILD_ARCH=amd64 --tag homeassistant-stelloauth-addon:test stelloauth + run: docker buildx build --platform linux/amd64 --build-arg BUILD_ARCH=amd64 --load --tag homeassistant-stelloauth-addon:test stelloauth - name: Exercise runtime run: SKIP_BUILD=1 tests/test_runtime.sh diff --git a/stelloauth/DOCS.md b/stelloauth/DOCS.md index d2344a6..687b4bc 100644 --- a/stelloauth/DOCS.md +++ b/stelloauth/DOCS.md @@ -52,7 +52,7 @@ 9. De målte resultater fra den reelle `linux/amd64`-kørsel under Rosetta var: - Image: 2,571,693,650 bytes (2.571 GB decimal / 2452.56 MiB). - - Seneste idle RAM: 121.5 MiB. + - Dokumenteret Task 4-måling: 121,5 MiB. - Stop: cirka 9.3 sekunder. - `amd64` runtime bestod under Rosetta; `aarch64` build bestod. - Mål-HAOS havde ved inspektionen approximately 4 GB free. Den knappe diff --git a/stelloauth/Dockerfile b/stelloauth/Dockerfile index 50c735e..ff0225e 100644 --- a/stelloauth/Dockerfile +++ b/stelloauth/Dockerfile @@ -27,7 +27,7 @@ ARG BUILD_VERSION="0.1.0" LABEL io.hass.name="$BUILD_NAME" \ io.hass.description="$BUILD_DESCRIPTION" \ io.hass.arch="$BUILD_ARCH" \ - io.hass.type="addon" \ + io.hass.type="app" \ io.hass.version="$BUILD_VERSION" \ org.opencontainers.image.created="$BUILD_DATE" \ org.opencontainers.image.revision="$BUILD_REF" \ diff --git a/stelloauth/patches/stelloauth-security.patch b/stelloauth/patches/stelloauth-security.patch index 5cd8b60..9d2e55c 100644 --- a/stelloauth/patches/stelloauth-security.patch +++ b/stelloauth/patches/stelloauth-security.patch @@ -157,10 +157,10 @@ index 48a487e..946d8cf 100644 flusher.Flush() return diff --git a/internal/app/server_test.go b/internal/app/server_test.go -index b56bb27..c9ab68e 100644 +index b56bb27..730e658 100644 --- a/internal/app/server_test.go +++ b/internal/app/server_test.go -@@ -1,7 +1,10 @@ +@@ -1,12 +1,16 @@ package app import ( @@ -171,7 +171,61 @@ index b56bb27..c9ab68e 100644 "net/http" "net/http/httptest" "os" -@@ -188,3 +191,74 @@ func TestParseClientIP(t *testing.T) { + "strings" + "testing" ++ "time" + ) + + func TestMain(m *testing.M) { +@@ -101,6 +105,47 @@ func TestHandleOAuth_InvalidBody(t *testing.T) { + } + } + ++func useSingleRequestRateLimiter(t *testing.T) { ++ t.Helper() ++ previous := rateLimiter ++ rateLimiter = &RateLimiter{ ++ requests: make(map[string][]time.Time), ++ refunds: make(map[string][]time.Time), ++ limit: 1, ++ window: time.Hour, ++ enabled: true, ++ } ++ t.Cleanup(func() { rateLimiter = previous }) ++} ++ ++func setSpoofedClientHeaders(req *http.Request, suffix string) { ++ req.Header.Set("Forwarded", "for=203.0.113."+suffix) ++ req.Header.Set("X-Forwarded-For", "198.51.100."+suffix) ++ req.Header.Set("X-Real-IP", "192.0.2."+suffix) ++} ++ ++func TestHandleOAuthRateLimitUsesDirectPeer(t *testing.T) { ++ useSingleRequestRateLimiter(t) ++ ++ first := httptest.NewRequest(http.MethodPost, "/oauth", strings.NewReader("invalid json")) ++ first.RemoteAddr = "10.0.0.8:41001" ++ setSpoofedClientHeaders(first, "11") ++ firstResponse := httptest.NewRecorder() ++ handleOAuth(firstResponse, first) ++ if firstResponse.Code != http.StatusBadRequest { ++ t.Fatalf("first status = %d, want %d", firstResponse.Code, http.StatusBadRequest) ++ } ++ ++ second := httptest.NewRequest(http.MethodPost, "/oauth", strings.NewReader("invalid json")) ++ second.RemoteAddr = "10.0.0.8:41001" ++ setSpoofedClientHeaders(second, "22") ++ secondResponse := httptest.NewRecorder() ++ handleOAuth(secondResponse, second) ++ if secondResponse.Code != http.StatusTooManyRequests { ++ t.Fatalf("second status = %d, want %d", secondResponse.Code, http.StatusTooManyRequests) ++ } ++} ++ + func TestHandleOAuth_MissingFields(t *testing.T) { + body := `{"brand":"MyPeugeot","country":"","email":"","password":""}` + req := httptest.NewRequest(http.MethodPost, "/oauth", strings.NewReader(body)) +@@ -188,3 +233,74 @@ func TestParseClientIP(t *testing.T) { } } } @@ -354,7 +408,7 @@ index 00b9cf8..3dbe54a 100644 // authorize URL's redirect_uri query parameter; the code-capture listener keys on // "://". diff --git a/internal/app/worker_test.go b/internal/app/worker_test.go -index e64964d..2a05a3b 100644 +index e64964d..7141640 100644 --- a/internal/app/worker_test.go +++ b/internal/app/worker_test.go @@ -2,12 +2,81 @@ package app @@ -439,10 +493,32 @@ index e64964d..2a05a3b 100644 func TestHandleWorker_MethodNotAllowed(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/worker", nil) w := httptest.NewRecorder() -@@ -30,6 +99,25 @@ func TestHandleWorker_InvalidBody(t *testing.T) { +@@ -30,6 +99,47 @@ func TestHandleWorker_InvalidBody(t *testing.T) { } } ++func TestHandleWorkerRateLimitUsesDirectPeer(t *testing.T) { ++ useSingleRequestRateLimiter(t) ++ ++ first := httptest.NewRequest(http.MethodPost, "/worker", strings.NewReader("invalid json")) ++ first.RemoteAddr = "10.0.0.9:41002" ++ setSpoofedClientHeaders(first, "33") ++ firstResponse := httptest.NewRecorder() ++ handleWorker(firstResponse, first) ++ if firstResponse.Code != http.StatusBadRequest { ++ t.Fatalf("first status = %d, want %d", firstResponse.Code, http.StatusBadRequest) ++ } ++ ++ second := httptest.NewRequest(http.MethodPost, "/worker", strings.NewReader("invalid json")) ++ second.RemoteAddr = "10.0.0.9:41002" ++ setSpoofedClientHeaders(second, "44") ++ secondResponse := httptest.NewRecorder() ++ handleWorker(secondResponse, second) ++ if secondResponse.Code != http.StatusTooManyRequests { ++ t.Fatalf("second status = %d, want %d", secondResponse.Code, http.StatusTooManyRequests) ++ } ++} ++ +func TestHandleWorker_RequestTooLarge(t *testing.T) { + body := `{"url":"` + strings.Repeat("x", 65<<10) + `"}` + req := httptest.NewRequest(http.MethodPost, "/worker", strings.NewReader(body)) @@ -465,7 +541,7 @@ index e64964d..2a05a3b 100644 func TestHandleWorker_MissingParams(t *testing.T) { body := `{"url":"","email":"","password":""}` req := httptest.NewRequest(http.MethodPost, "/worker", strings.NewReader(body)) -@@ -83,7 +171,7 @@ func TestRedirectScheme(t *testing.T) { +@@ -83,7 +193,7 @@ func TestRedirectScheme(t *testing.T) { }, { name: "opel custom scheme", diff --git a/tests/test_addon_metadata.py b/tests/test_addon_metadata.py index 7a3ef15..630d8fd 100644 --- a/tests/test_addon_metadata.py +++ b/tests/test_addon_metadata.py @@ -51,26 +51,40 @@ def test_repository_hostname_derivation() -> None: assert f"{repository_id}-stelloauth" == "0031621f-stelloauth" -def test_documentation_contract() -> None: - documentation = "\n".join( - (ROOT / path).read_text(encoding="utf-8") - for path in ("README.md", "stelloauth/README.md", "stelloauth/DOCS.md") - ) +def test_user_guide_documentation_contract() -> None: + documentation = (ROOT / "stelloauth/DOCS.md").read_text(encoding="utf-8") for required_text in ( REPOSITORY_URL, + "Installér **Stelloauth**", + "aktivér **Start ved opstart** og **Watchdog**", "http://0031621f-stelloauth:8080/worker", "http://192.168.1.20:8080/worker", "Brand: Opel", "Country: DK", - "v0.6.0", - "0.5.10", - "CloakBrowser Binary License", + "Image: 2,571,693,650 bytes (2.571 GB decimal / 2452.56 MiB).", + "Dokumenteret Task 4-måling: 121,5 MiB.", + "Stop: cirka 9.3 sekunder.", "approximately 4 GB free", "Der er ikke gennemført et live MyOpel-login.", "Login-flow RAM: not measured without real MyOpel credentials.", ): assert required_text in documentation assert "deaktivér porttilknytningen igen" in documentation + assert "Seneste idle RAM" not in documentation + + +def test_root_readme_repository_source_and_license_facts() -> None: + readme = (ROOT / "README.md").read_text(encoding="utf-8") + for required_text in ( + REPOSITORY_URL, + "v0.6.0", + "367d4f8c02a3b072c59142c49dffc129edc8548b", + "0.5.10", + "f04c23da285b3b3d3cf10c8f9d282e7adc1d52ce", + "CloakBrowser Binary License", + "MIT-licenseret", + ): + assert required_text in readme def test_translations_cover_every_option() -> None: keys = set(load_yaml("stelloauth/config.yaml")["options"]) @@ -107,6 +121,8 @@ def test_dockerfile_declares_home_assistant_runtime_contract() -> None: "io.hass.version", ): assert label in dockerfile + assert 'io.hass.type="app"' in dockerfile + assert 'io.hass.type="addon"' not in dockerfile assert re.search(r"^EXPOSE 8080$", dockerfile, re.MULTILINE) assert not re.search(r"^EXPOSE .*\b9222\b", dockerfile, re.MULTILINE) assert "ENTRYPOINT []" in dockerfile @@ -127,6 +143,64 @@ def test_runtime_accepts_docker_port_unpublished_status() -> None: assert 'docker port "$container" 9222/tcp 2>/dev/null || true' in runtime_test +def test_ci_builds_and_loads_only_the_amd64_test_image() -> None: + workflow = (ROOT / ".gitea/workflows/ci.yml").read_text(encoding="utf-8") + parsed = yaml.safe_load(workflow) + steps = parsed["jobs"]["validate"]["steps"] + build_command = next( + step["run"] for step in steps if step.get("name") == "Build amd64 image" + ) + assert build_command.split() == [ + "docker", + "buildx", + "build", + "--platform", + "linux/amd64", + "--build-arg", + "BUILD_ARCH=amd64", + "--load", + "--tag", + "homeassistant-stelloauth-addon:test", + "stelloauth", + ] + for publication_primitive in ( + "--push", + "docker push", + "docker/login-action", + "docker/build-push-action", + "packages: write", + ): + assert publication_primitive not in workflow + + +def test_patch_payloads_disable_git_whitespace_errors() -> None: + result = subprocess.run( + [ + "git", + "check-attr", + "whitespace", + "--", + "stelloauth/patches/stelloauth-security.patch", + "stelloauth/patches/cloakserve-loopback.patch", + ], + cwd=ROOT, + text=True, + capture_output=True, + check=True, + ) + assert result.stdout.splitlines() == [ + "stelloauth/patches/stelloauth-security.patch: whitespace: unset", + "stelloauth/patches/cloakserve-loopback.patch: whitespace: unset", + ] + + +def test_runtime_rejects_every_ipv6_cdp_listener() -> None: + runtime_test = (ROOT / "tests/test_runtime.sh").read_text(encoding="utf-8") + assert 'cat /proc/net/tcp6 > "$tcp6_artifact"' in runtime_test + assert 'for table in ("/proc/net/tcp", "/proc/net/tcp6"):' in runtime_test + assert 'if table == "/proc/net/tcp6":' in runtime_test + + def test_runtime_requires_process_baseline_after_cdp_close_and_zero_stopped_pid() -> None: runtime_test = (ROOT / "tests/test_runtime.sh").read_text(encoding="utf-8") assert 'docker top "$container" -eo pid,args' in runtime_test diff --git a/tests/test_runtime.sh b/tests/test_runtime.sh index 634570d..f8c83d2 100755 --- a/tests/test_runtime.sh +++ b/tests/test_runtime.sh @@ -209,26 +209,34 @@ assert_processes_return_to_baseline() { assert_loopback_cdp_listener() { local container="$1" - local artifact="$2" - docker exec "$container" cat /proc/net/tcp > "$artifact" + local tcp_artifact="$2" + local tcp6_artifact="$3" + docker exec "$container" cat /proc/net/tcp > "$tcp_artifact" + docker exec "$container" cat /proc/net/tcp6 > "$tcp6_artifact" docker exec -i "$container" python3 - <<'PY' expected = f"0100007F:{9222:04X}" -wildcard = f"00000000:{9222:04X}" if expected != "0100007F:2406": raise SystemExit(f"unexpected 9222 hexadecimal encoding: {expected}") -listeners = set() -with open("/proc/net/tcp", encoding="ascii") as handle: - next(handle) - for line in handle: - fields = line.split() - if len(fields) >= 4 and fields[3] == "0A": - listeners.add(fields[1].upper()) +listeners = {"/proc/net/tcp": set(), "/proc/net/tcp6": set()} +for table in ("/proc/net/tcp", "/proc/net/tcp6"): + with open(table, encoding="ascii") as handle: + next(handle) + for line in handle: + fields = line.split() + if len(fields) < 4 or fields[3] != "0A": + continue + local_address = fields[1].upper() + if local_address.rsplit(":", 1)[-1] != "2406": + continue + listeners[table].add(local_address) + if table == "/proc/net/tcp6": + raise SystemExit(f"IPv6 CDP listener present: {local_address}") -if expected not in listeners: - raise SystemExit(f"missing loopback CDP listener {expected}: {sorted(listeners)}") -if wildcard in listeners: - raise SystemExit(f"wildcard CDP listener present: {wildcard}") +if listeners["/proc/net/tcp"] != {expected}: + raise SystemExit( + f"IPv4 CDP listeners = {sorted(listeners['/proc/net/tcp'])}, want [{expected}]" + ) PY } @@ -370,7 +378,10 @@ first_port="$(host_port "$first_container")" wait_ready "$first_container" "$first_port" first_baseline="$(capture_process_baseline "$first_container")" assert_no_9222_mapping "$first_container" -assert_loopback_cdp_listener "$first_container" "${artifacts_dir}/runtime-proc-net-tcp.txt" +assert_loopback_cdp_listener \ + "$first_container" \ + "${artifacts_dir}/runtime-proc-net-tcp.txt" \ + "${artifacts_dir}/runtime-proc-net-tcp6.txt" probe_and_close_cdp "$first_container" assert_processes_return_to_baseline "$first_container" "$first_baseline" post_invalid_worker "$first_port" "${artifacts_dir}/runtime-invalid-worker-response.json" @@ -383,7 +394,10 @@ second_port="$(host_port "$second_container")" wait_ready "$second_container" "$second_port" second_baseline="$(capture_process_baseline "$second_container")" assert_no_9222_mapping "$second_container" -assert_loopback_cdp_listener "$second_container" "${artifacts_dir}/runtime-restart-proc-net-tcp.txt" +assert_loopback_cdp_listener \ + "$second_container" \ + "${artifacts_dir}/runtime-restart-proc-net-tcp.txt" \ + "${artifacts_dir}/runtime-restart-proc-net-tcp6.txt" probe_and_close_cdp "$second_container" assert_processes_return_to_baseline "$second_container" "$second_baseline"