Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ repositories {
}

dependencies {
implementation platform('run.halo.tools.platform:plugin:2.24.0')
implementation platform('run.halo.tools.platform:plugin:2.25.0')
compileOnly 'run.halo.app:api'
implementation('com.rometools:rome:2.1.0') {
exclude group: 'org.slf4j', module: 'slf4j-api'
Expand Down Expand Up @@ -63,7 +63,7 @@ tasks.named('generatePluginComponentsIdx') {
}

halo {
version = '2.24'
version = '2.25'
port = 8091
debug = true
}
Expand Down
2 changes: 2 additions & 0 deletions openspec/changes/use-halo-http-security-utils/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-14
168 changes: 168 additions & 0 deletions openspec/changes/use-halo-http-security-utils/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
## Context

`SafeUrlFetcher` is the shared outbound fetch boundary for link detail scraping,
RSS/Atom discovery and fetching, and link verification. It currently combines
several concerns in plugin-owned code:

- URL scheme and resolved-address validation in `LinkSecurityUtils`.
- Manual redirect handling and redirect target validation.
- HTTP fetching through Jsoup for HTTP URLs.
- A custom HTTPS socket path that pins the connection to a validated address.
- Response body size checks, charset decoding, status/header capture, and
optional Jsoup document parsing.

Halo 2.25 moved `run.halo.app.infra.utils.HttpSecurityUtils` into the API module,
so plugins can now use the same secure Reactor Netty client and response-size
filter as Halo itself. The utility exposes `secureHttpClient()` and
`maxResponseSizeFilter(long)`.

## Goals / Non-Goals

**Goals:**

- Upgrade the plugin build and local dev runtime to Halo 2.25.0.
- Raise the plugin runtime requirement to `>=2.25.0`.
- Replace plugin-maintained SSRF networking code with Halo API-provided HTTP
security utilities.
- Preserve the current `SafeUrlFetcher.FetchOptions` and `FetchResult` contract
for link detail, RSS, and verification callers.
- Preserve redirect validation, redirect limits, body-size limits, timeouts,
status code handling, final URL reporting, `ETag`, `Last-Modified`, and HTML
document parsing where callers rely on them.

**Non-Goals:**

- No Console UI changes.
- No public endpoint or generated TypeScript API client changes.
- No new outbound proxy, allowlist, or operator-configurable HTTP policy.
- No change to Halo's `HttpSecurityUtils` behavior inside this plugin change.

## Decisions

### 1. Require Halo 2.25.0 directly

The implementation should update the platform dependency, dev runtime, and
plugin manifest requirement together:

- `run.halo.tools.platform:plugin:2.25.0`
- `halo.version = '2.25'`
- `spec.requires: ">=2.25.0"`

**Rationale:** importing `HttpSecurityUtils` from the API module is only safe for
plugin runtime environments that provide Halo 2.25 or newer.

**Alternative considered:** keep `>=2.22.5` and use reflection or a fallback
fetcher. That would preserve compatibility but keep two security implementations
alive, which is the maintenance problem this change is meant to remove.

### 2. Keep `SafeUrlFetcher` as the plugin adapter

`LinkRequest`, `LinkFeedFetcher`, and `LinkVerificationFetcher` should keep using
`SafeUrlFetcher.fetch(...)`. The internals of `SafeUrlFetcher` should move to a
WebFlux `WebClient` backed by:

- `ReactorClientHttpConnector(HttpSecurityUtils.secureHttpClient())`
- `HttpSecurityUtils.maxResponseSizeFilter(options.maxBodySize)`

**Rationale:** callers already depend on the fetcher's small result model rather
than raw HTTP APIs. Keeping that adapter makes the refactor surgical and avoids
cross-service churn.

**Alternative considered:** inject `WebClient` into every caller and let each
service interpret responses. That spreads redirect, timeout, response-size, and
header handling across unrelated services.

### 3. Preserve per-operation timeout behavior

The secure client returned by Halo sets secure defaults, including disabled
automatic redirects. `SafeUrlFetcher` should derive the Reactor Netty client from
`HttpSecurityUtils.secureHttpClient()` and then apply the current operation's
timeout from `FetchOptions` so link verification can keep its shorter timeout.

**Rationale:** RSS fetching currently uses the default 10s timeout, while link
verification uses 5s. The security utility should replace the SSRF mechanism, not
silently change verification latency.

**Alternative considered:** accept Halo's default timeout for every operation.
That is simpler but changes verification behavior.

### 4. Keep manual redirect handling in the fetcher

`HttpSecurityUtils.secureHttpClient()` disables automatic redirects. The fetcher
should continue to inspect redirect responses, resolve relative `Location`
headers against the current URL, enforce the redirect limit, and re-run each hop
through the same secure WebClient.

**Rationale:** redirect targets are separate outbound connections. Sending every
hop through the secure client preserves the existing safety boundary without
maintaining plugin-local IP filtering code.

**Alternative considered:** enable automatic redirects in the Reactor Netty
client. That would make redirect behavior harder to reason about and bypass the
plugin's explicit hop limit.

### 5. Use response exchange APIs, not `retrieve()`

The fetcher should use an API that exposes status and headers before body
decoding, such as `exchangeToMono`, then build `FetchResult` from:

- final URL
- HTTP status code
- response body
- parsed Jsoup document when `FetchOptions.parseDocument` is true
- `ETag`
- `Last-Modified`

**Rationale:** callers use non-2xx status codes as data. For example, RSS refresh
handles `304 Not Modified`, and verification records HTTP status in link status.
`retrieve()` would turn many of these responses into exceptions.

**Alternative considered:** use `retrieve()` and map exceptions back to status
results. That is more fragile and obscures normal non-2xx outcomes.

### 6. Normalize fetch errors at the adapter boundary

`SafeUrlFetcher` should continue throwing `ServerErrorException` for invalid
URLs, blocked URLs, redirects, body-size violations, and IO/client failures. For
link detail requests, the endpoint should continue converting invalid or blocked
URL failures into HTTP 400.

When `FetchOptions.allowOversizedBody` is true, the fetcher should preserve the
status code and return an empty body if the response body exceeds the configured
limit. This preserves the current reachability-check behavior.

**Rationale:** the refactor changes the lower-level exception types from custom
validation/IO errors to WebClient/Reactor/DataBuffer errors. Callers should not
need to learn those details.

**Alternative considered:** let WebClient exceptions propagate directly. That
would make endpoint and status error messages less stable.

## Risks / Trade-offs

| Risk | Mitigation |
|------|------------|
| Halo's blocked address set is stricter than the plugin-local filter and may block additional special-use ranges | Accept the stricter platform policy because these are external friend-link requests, not intranet fetches |
| Response decoding may change if the refactor uses WebClient string decoding directly | Preserve the current charset-aware body decoding by reading bytes and decoding from `Content-Type` where practical |
| Redirect handling could accidentally lose `ETag` or `Last-Modified` from the final response | Add tests around RSS fetch results and `304 Not Modified` behavior |
| Tests that mocked Jsoup/static URL utilities will no longer cover the new path | Replace them with tests at the `SafeUrlFetcher` boundary and service-level tests that assert behavior rather than implementation details |

## Migration Plan

1. Upgrade Halo dependencies and plugin runtime metadata.
2. Refactor `SafeUrlFetcher` internals to use Halo's secure HTTP utilities while
retaining the existing public nested types.
3. Delete plugin-local `LinkSecurityUtils` and `RedirectHandler` once no code or
tests reference them.
4. Update tests to cover blocked private/local targets, redirect blocking,
response size limits, RSS conditional headers, and verification status
preservation.
5. Run `./gradlew test` and `./gradlew build`.

Rollback is a normal code rollback of this change. Because the manifest minimum
Halo version changes, the dependency and manifest updates should be reverted
together if the refactor is rolled back.

## Open Questions

- None.
46 changes: 46 additions & 0 deletions openspec/changes/use-halo-http-security-utils/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
## Why

Halo 2.25 exposes `HttpSecurityUtils` from the API module so plugins can reuse the
same SSRF-safe HTTP client and response-size filter used by Halo itself. The
links plugin currently maintains its own SSRF-safe fetcher stack, which duplicates
platform logic and increases long-term maintenance risk.

## What Changes

- Upgrade the Halo plugin platform/API dependency and local dev runtime to
Halo 2.25.0.
- **BREAKING**: raise the plugin's minimum supported Halo version to `>=2.25.0`
because the implementation will import API classes first exposed to plugins in
Halo 2.25.
- Refactor external URL fetching for link details, RSS discovery/fetching, and
link verification to use `run.halo.app.infra.utils.HttpSecurityUtils`:
- `secureHttpClient()` for SSRF-safe outbound connections.
- `maxResponseSizeFilter(long)` for response body bounds.
- Preserve the plugin-facing fetch behavior needed by existing services:
status code, final URL, response body, parsed HTML document when requested,
`ETag`, and `Last-Modified`.
- Remove the plugin-local SSRF utility and manual socket/redirect code after the
Halo utility-backed fetcher covers the same safety boundaries.

## Capabilities

### New Capabilities

- *(none)*

### Modified Capabilities

- `ssrf-protection`: external HTTP requests continue to be protected from
private, local, and special-use network targets, but the security enforcement
is delegated to Halo 2.25's API-provided HTTP security utilities instead of
plugin-maintained URL/IP validation and pinned socket code.

## Impact

- **Backend**: `build.gradle`, `plugin.yaml`, `run.halo.links.security`, and the
tests around link detail, RSS fetching, and verification.
- **Frontend**: no Console UI changes expected.
- **API**: no endpoint contract or generated TypeScript client changes expected.
- **Dependencies**: Halo platform/API moves from 2.24.0 to 2.25.0; no new
third-party dependency should be needed because WebFlux/Reactor Netty are
already available through Halo.
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
## ADDED Requirements

### Requirement: External fetches use Halo HTTP security utilities
The system SHALL execute plugin-originated external HTTP(S) requests through
Halo API-provided HTTP security utilities.

#### Scenario: Link detail fetch uses the secure client
- **WHEN** the system fetches an external URL to extract link detail metadata
- **THEN** it uses an HTTP client based on `HttpSecurityUtils.secureHttpClient()`

#### Scenario: RSS feed fetch uses the secure client
- **WHEN** the system fetches an external URL for RSS/Atom discovery or feed refresh
- **THEN** it uses an HTTP client based on `HttpSecurityUtils.secureHttpClient()`

#### Scenario: Link verification fetch uses the secure client
- **WHEN** the system fetches an external URL for reachability or backlink verification
- **THEN** it uses an HTTP client based on `HttpSecurityUtils.secureHttpClient()`

#### Scenario: Response size filter is applied
- **WHEN** the system consumes an external HTTP response body
- **THEN** it applies `HttpSecurityUtils.maxResponseSizeFilter(long)` using the
fetch operation's configured maximum response size

#### Scenario: Plugin runtime requirement matches the security utility dependency
- **WHEN** the plugin is packaged
- **THEN** the plugin manifest declares Halo `>=2.25.0` as the minimum runtime
version

## MODIFIED Requirements

### Requirement: URL validation prevents DNS Rebinding attacks
The system SHALL use Halo's SSRF-safe HTTP client for outbound external HTTP(S)
connections so private, local, and special-use resolved addresses are rejected
before a connection is opened.

#### Scenario: DNS Rebinding attack is blocked
- **WHEN** a link detail request is made with a URL whose hostname resolves to a
private, local, or special-use address at connection time
- **THEN** the system rejects the request before connecting

#### Scenario: Direct private IP access is blocked
- **WHEN** a link detail request is made with a URL pointing to `http://192.168.1.1/`
- **THEN** the system rejects the request with an error indicating private address access is not allowed
39 changes: 39 additions & 0 deletions openspec/changes/use-halo-http-security-utils/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
## 1. Upgrade Halo runtime contract

- [x] 1.1 Update `build.gradle` to use `run.halo.tools.platform:plugin:2.25.0`.
- [x] 1.2 Update the `halo { version = ... }` local dev runtime to Halo 2.25.
- [x] 1.3 Update `src/main/resources/plugin.yaml` to require Halo `>=2.25.0`.
- [x] 1.4 Compile enough of the backend to confirm `run.halo.app.infra.utils.HttpSecurityUtils` is available from the plugin API dependency.

## 2. Refactor the shared external URL fetcher

- [x] 2.1 Refactor `SafeUrlFetcher` to build its HTTP client from `HttpSecurityUtils.secureHttpClient()`.
- [x] 2.2 Apply `HttpSecurityUtils.maxResponseSizeFilter(options.maxBodySize)` whenever response bodies are consumed.
- [x] 2.3 Preserve the existing `FetchOptions` and `FetchResult` contract, including status code, final URL, body, parsed document, `ETag`, and `Last-Modified`.
- [x] 2.4 Preserve current request headers, including `User-Agent`, `Accept`, `Referer`, `If-None-Match`, and `If-Modified-Since`.
- [x] 2.5 Preserve per-operation timeout behavior, including the shorter verification timeout.
- [x] 2.6 Keep explicit redirect handling with relative `Location` resolution, a 3-hop limit, and secure-client execution for every hop.
- [x] 2.7 Normalize invalid URL, blocked URL, redirect, response-size, and client failures to the existing `ServerErrorException` boundary.
- [x] 2.8 Preserve `allowOversizedBody` behavior for reachability checks by returning the response status with an empty body when the body exceeds the configured limit.

## 3. Remove duplicate plugin-local security code

- [x] 3.1 Remove `LinkSecurityUtils` after `SafeUrlFetcher` no longer depends on plugin-local address validation.
- [x] 3.2 Remove `RedirectHandler` after redirect handling is consolidated in the Halo utility-backed fetcher.
- [x] 3.3 Search the backend for remaining external fetch paths and verify link detail, RSS, and verification all go through the shared fetcher.
- [x] 3.4 Confirm no OpenAPI client regeneration is required because endpoint shapes, extension models, and DTO contracts did not change.

## 4. Update tests

- [x] 4.1 Replace tests that mock `LinkSecurityUtils`, `RedirectHandler`, or `Jsoup.connect` internals with tests against the `SafeUrlFetcher` behavior boundary.
- [x] 4.2 Cover direct private/local target blocking for link detail, RSS, and verification fetches.
- [x] 4.3 Cover non-HTTP(S) URL rejection and bad URL error normalization.
- [x] 4.4 Cover redirect-to-private target rejection and redirect limit enforcement.
- [x] 4.5 Cover response-size enforcement and the reachability `allowOversizedBody` status-preservation path.
- [x] 4.6 Cover RSS response metadata preservation, including `ETag`, `Last-Modified`, and `304 Not Modified`.

## 5. Verify

- [x] 5.1 Run `./gradlew test`.
- [x] 5.2 Run `./gradlew build`.
- [x] 5.3 Run `openspec validate use-halo-http-security-utils --strict`.
Loading