Skip to content
Draft
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
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ Run from the repository root:
BUILD_TAG="$(date +%F)" \
GIT_SHA="trial" \
HTML2RSS_SECRET_KEY="$(openssl rand -hex 32)" \
HEALTH_CHECK_TOKEN="$(openssl rand -hex 24)" \
BROWSERLESS_IO_API_TOKEN="trial-browserless-token" \
HTML2RSS_ACCESS_TOKEN="$(openssl rand -hex 24)" \
AUTO_SOURCE_ENABLED=true \
docker compose up -d
```

Expand All @@ -62,9 +62,13 @@ The checked-in [`docker-compose.yml`](docker-compose.yml) requires these environ
- `BUILD_TAG`
- `GIT_SHA`
- `HTML2RSS_SECRET_KEY`
- `HEALTH_CHECK_TOKEN`
- `BROWSERLESS_IO_API_TOKEN`

For the simple page-URL onboarding path, also set:

- `HTML2RSS_ACCESS_TOKEN`
- `AUTO_SOURCE_ENABLED=true`

Optional runtime variables:

- `SENTRY_DSN`
Expand All @@ -74,8 +78,8 @@ Example:

```bash
export HTML2RSS_SECRET_KEY="$(openssl rand -hex 32)"
export HEALTH_CHECK_TOKEN="replace-with-a-strong-token"
export BROWSERLESS_IO_API_TOKEN="replace-with-your-browserless-token"
export HTML2RSS_ACCESS_TOKEN="replace-with-a-strong-access-token"
export BUILD_TAG="local"
export GIT_SHA="$(git rev-parse --short HEAD 2>/dev/null || echo dev)"
export AUTO_SOURCE_ENABLED=true
Expand All @@ -87,7 +91,7 @@ docker compose up -d

- In production, missing `HTML2RSS_SECRET_KEY` stops startup.
- `BUILD_TAG` and `GIT_SHA` are expected in production; missing values produce a startup warning.
- `POST /api/v1/feeds` requires a bearer token and only works when `AUTO_SOURCE_ENABLED=true`.
- `POST /api/v1/feeds` requires a bearer token and only works when `AUTO_SOURCE_ENABLED=true`; the bundled `config/feeds.yml` reads the main token from `HTML2RSS_ACCESS_TOKEN` when it is set.
- `AUTO_SOURCE_ENABLED` defaults to `true` in development/test and `false` otherwise.
- Strategy support comes from `Html2rss::RequestService` (`faraday` and `browserless` availability is runtime-dependent).

Expand Down
2 changes: 1 addition & 1 deletion app/web/api/v1/health.rb
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ def bearer_token(request)

# @return [void]
def verify_configuration!
LocalConfig.yaml
LocalConfig.snapshot
rescue StandardError
raise Html2rss::Web::HealthCheckFailedError
end
Expand Down
24 changes: 24 additions & 0 deletions app/web/config/environment_validator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ module Web
# Environment validation for html2rss-web
# Handles validation of environment variables and configuration
module EnvironmentValidator # rubocop:disable Metrics/ModuleLength
PLACEHOLDER_CREATE_FEED_TOKEN = 'CHANGE_ME_ADMIN_TOKEN'

# rubocop:disable Metrics/ClassLength
class << self
##
Expand Down Expand Up @@ -105,12 +107,34 @@ def validate_build_metadata!

def validate_account_configuration!
accounts = AccountManager.accounts
validate_create_feed_token!(accounts)
weak_tokens = accounts.select { |acc| acc[:token].length < 16 }
return unless weak_tokens.any?

handle_weak_account_tokens!(weak_tokens)
end

# @param accounts [Array<Hash{Symbol=>Object}>]
# @return [void]
def validate_create_feed_token!(accounts)
return unless auto_source_enabled?

full_access_account = accounts.find do |account|
account[:token] == PLACEHOLDER_CREATE_FEED_TOKEN && Array(account[:allowed_urls]).include?('*')
end
return unless full_access_account

SecurityLogger.log_config_validation_failure(
'access_token',
'Placeholder create-feed token is not allowed when auto source is enabled'
)
warn_lines(
'CRITICAL: Placeholder create-feed token detected in production!',
'Set HTML2RSS_ACCESS_TOKEN to a strong token before enabling automatic feed generation.'
)
exit 1
end

# @param lines [Array<String>]
# @return [void]
def warn_lines(*lines)
Expand Down
10 changes: 9 additions & 1 deletion app/web/config/local_config.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# frozen_string_literal: true

require 'erb'
require 'yaml'
require_relative 'runtime_env'
begin
require 'html2rss/configs'
rescue LoadError => error
Expand Down Expand Up @@ -58,7 +60,7 @@ def global
##
# @return [Hash<Symbol, Any>]
def yaml
YAML.safe_load_file(CONFIG_FILE, symbolize_names: true).freeze
YAML.safe_load(rendered_yaml, symbolize_names: true).freeze
rescue Errno::ENOENT => error
raise NotFound, "Configuration file not found: #{error.message}"
end
Expand All @@ -82,6 +84,12 @@ def reload!(reason: 'manual')

private

# @return [String]
def rendered_yaml
template = File.read(CONFIG_FILE)
ERB.new(template, trim_mode: '-').result
end

# @param normalized_name [String]
# @return [Hash{Symbol=>Object}, nil]
def local_feed_config(normalized_name)
Expand Down
7 changes: 6 additions & 1 deletion app/web/config/runtime_env.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ module Web
# Captures boot-time environment configuration and scrubs selected secrets
# from the process environment after validation.
module RuntimeEnv
SENSITIVE_KEYS = %w[HTML2RSS_SECRET_KEY HEALTH_CHECK_TOKEN SENTRY_DSN].freeze
SENSITIVE_KEYS = %w[HTML2RSS_SECRET_KEY HTML2RSS_ACCESS_TOKEN HEALTH_CHECK_TOKEN SENTRY_DSN].freeze
BOOT_METADATA_KEYS = %w[BUILD_TAG GIT_SHA RACK_ENV SENTRY_ENABLE_LOGS].freeze
@mutex = Mutex.new
@values = nil
Expand Down Expand Up @@ -34,6 +34,11 @@ def health_check_token
fetch('HEALTH_CHECK_TOKEN', '')
end

# @return [String]
def access_token
fetch('HTML2RSS_ACCESS_TOKEN', '')
end

# @return [String, nil]
def sentry_dsn
fetch('SENTRY_DSN', nil)
Expand Down
2 changes: 1 addition & 1 deletion config/feeds.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
auth:
accounts:
- username: "admin"
token: "CHANGE_ME_ADMIN_TOKEN"
token: "<%= Html2rss::Web::RuntimeEnv.access_token.to_s.strip.empty? ? 'CHANGE_ME_ADMIN_TOKEN' : Html2rss::Web::RuntimeEnv.access_token %>"
allowed_urls:
- "*" # Full access
- username: "demo"
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ Managed flags and environment keys:
| `auto_source_enabled` | `AUTO_SOURCE_ENABLED` | boolean | `true` in development/test, else `false` |
| `async_feed_refresh_enabled` | `ASYNC_FEED_REFRESH_ENABLED` | boolean | `false` |
| `async_feed_refresh_stale_factor` | `ASYNC_FEED_REFRESH_STALE_FACTOR` | integer `>= 1` | `3` |
| `access_token` | `HTML2RSS_ACCESS_TOKEN` | string | `''` |
| `health_check_token` | `HEALTH_CHECK_TOKEN` | string | `nil` |
| `build_tag` | `BUILD_TAG` | string | `unknown` outside production |
| `git_sha` | `GIT_SHA` | string | `unknown` outside production |
Expand Down
16 changes: 11 additions & 5 deletions frontend/src/__tests__/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -191,8 +191,14 @@ describe('App', () => {
expect(screen.getByLabelText('Page URL')).toBeDisabled();
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
expect(screen.getByLabelText('Utilities')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Set up your own instance with Docker.' })).toBeInTheDocument();
expect(screen.getByText('Required by this instance.')).toBeInTheDocument();
expect(
screen.getByRole('link', {
name: 'Copy `docker-compose.yml`, copy `.env`, start the stack, then paste the token.',
})
).toBeInTheDocument();
expect(
screen.getByText('Use the `HTML2RSS_ACCESS_TOKEN` from your instance `.env` or setup.')
).toBeInTheDocument();
expect(screen.queryByText('Paste an access token to keep going.')).not.toBeInTheDocument();
await waitFor(() => {
expect(document.activeElement).toBe(document.querySelector('#access-token'));
Expand Down Expand Up @@ -363,7 +369,7 @@ describe('App', () => {
].map((element) => element.textContent);

expect(utilityItems).toEqual([
'Try included feeds',
'Browse included feeds',
'Bookmarklet',
'Logout',
'Install from Docker Hub',
Expand Down Expand Up @@ -654,7 +660,7 @@ describe('App', () => {
...screen.getByLabelText('Utilities').querySelectorAll('.utility-strip__items > a'),
].map((link) => link.textContent);
expect(utilityLinks).toEqual([
'Try included feeds',
'Browse included feeds',
'Bookmarklet',
'Install from Docker Hub',
'OpenAPI spec',
Expand All @@ -665,7 +671,7 @@ describe('App', () => {
'href',
'http://example.test/openapi.yaml'
);
expect(screen.getByRole('link', { name: 'Try included feeds' })).toHaveAttribute(
expect(screen.getByRole('link', { name: 'Browse included feeds' })).toHaveAttribute(
'href',
'https://html2rss.github.io/feed-directory/#!url=http%3A%2F%2Flocalhost%3A3000%2F'
);
Expand Down
8 changes: 5 additions & 3 deletions frontend/src/components/AppPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,9 @@ export function CreateFeedPanel({
<div class="token-gate" role="group" aria-label="Access token">
<div class="token-gate__copy">
<h2>Enter access token</h2>
<p class="token-gate__hint">Required by this instance.</p>
<p class="token-gate__hint">
Use the `HTML2RSS_ACCESS_TOKEN` from your instance `.env` or setup.
</p>
</div>
<label class="field-block field-block--stretch field-block--compact" htmlFor="access-token">
<span class="field-label field-label--ghost">Access token</span>
Expand Down Expand Up @@ -192,7 +194,7 @@ export function CreateFeedPanel({
rel="noopener noreferrer"
class="token-gate__nudge token-gate__nudge-link"
>
Set up your own instance with Docker.
Copy `docker-compose.yml`, copy `.env`, start the stack, then paste the token.
</a>
<div class="token-gate__actions">
<button type="button" class="btn btn--primary" onClick={onSaveToken}>
Expand Down Expand Up @@ -257,7 +259,7 @@ export function UtilityStrip({ hasAccessToken, openapiUrl, onClearToken }: Utili
<section class="utility-strip" aria-label="Utilities">
<div class="utility-strip__items">
<a href={includedFeedsHref} target="_blank" rel="noopener noreferrer" class="utility-link">
Try included feeds
Browse included feeds
</a>
<Bookmarklet />
{hasAccessToken && (
Expand Down
14 changes: 13 additions & 1 deletion spec/html2rss/web/api/v1_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@ def expected_featured_feeds

it 'returns error when configuration fails', :aggregate_failures do
allow(Html2rss::Web::Auth).to receive(:authenticate).and_return({ username: 'health-check' })
allow(Html2rss::Web::LocalConfig).to receive(:yaml).and_raise(StandardError, 'boom')
allow(Html2rss::Web::LocalConfig).to receive(:snapshot).and_raise(StandardError, 'boom')
header 'Authorization', "Bearer #{health_token}"

get '/api/v1/health'
Expand Down Expand Up @@ -318,6 +318,18 @@ def expected_featured_feeds
json = expect_success_response(last_response)
expect(json.dig('data', 'health', 'status')).to eq('healthy')
end

it 'uses the effective runtime snapshot instead of reparsing raw yaml bytes', :aggregate_failures do
allow(Html2rss::Web::LocalConfig).to receive(:snapshot)
.and_return(Html2rss::Web::ConfigSnapshot::Snapshot.new(global: {}, feeds: {}, accounts: []))
allow(Html2rss::Web::LocalConfig).to receive(:yaml).and_raise(StandardError, 'stale raw yaml')

get '/api/v1/health/ready'

expect(last_response.status).to eq(200)
json = expect_success_response(last_response)
expect(json.dig('data', 'health', 'status')).to eq('healthy')
end
end

describe 'GET /api/v1/health/live', openapi: {
Expand Down
4 changes: 4 additions & 0 deletions spec/html2rss/web/boot/setup_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
it 'captures and scrubs sensitive env vars after validation', :aggregate_failures do
allow(Html2rss::Web::EnvironmentValidator).to receive(:validate_environment!).ordered do
expect(ENV.fetch('HTML2RSS_SECRET_KEY', nil)).to eq(boot_secret_key)
expect(ENV.fetch('HTML2RSS_ACCESS_TOKEN', nil)).to eq('access-token')
expect(ENV.fetch('HEALTH_CHECK_TOKEN', nil)).to eq('health-token')
end
allow(Html2rss::Web::EnvironmentValidator).to receive(:validate_production_security!).ordered do
Expand Down Expand Up @@ -130,19 +131,22 @@ def stub_environment_validation

def scrubbed_env
boot_env.merge(
'HTML2RSS_ACCESS_TOKEN' => 'access-token',
'HEALTH_CHECK_TOKEN' => 'health-token',
'SENTRY_DSN' => sentry_dsn
)
end

def expect_runtime_env_to_match_boot_values
expect(Html2rss::Web::RuntimeEnv.secret_key).to eq(boot_secret_key)
expect(Html2rss::Web::RuntimeEnv.access_token).to eq('access-token')
expect(Html2rss::Web::RuntimeEnv.health_check_token).to eq('health-token')
expect(Html2rss::Web::RuntimeEnv.sentry_dsn).to eq(sentry_dsn)
end

def expect_sensitive_env_to_be_scrubbed
expect(ENV.fetch('HTML2RSS_SECRET_KEY', nil)).to be_nil
expect(ENV.fetch('HTML2RSS_ACCESS_TOKEN', nil)).to be_nil
expect(ENV.fetch('HEALTH_CHECK_TOKEN', nil)).to be_nil
expect(ENV.fetch('SENTRY_DSN', nil)).to be_nil
end
Expand Down
20 changes: 20 additions & 0 deletions spec/html2rss/web/environment_validator_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,26 @@ def stub_validation_logging
expect(Html2rss::Web::SecurityLogger).to have_received(:log_config_validation_failure)
.with('build_metadata', 'Missing BUILD_TAG or GIT_SHA', severity: :warn)
end

it 'fails boot when auto source is enabled with the placeholder create-feed token in production' do
stub_validation_logging
allow(Html2rss::Web::AccountManager).to receive(:accounts).and_return(
[{ username: 'admin', token: 'CHANGE_ME_ADMIN_TOKEN', allowed_urls: ['*'] }]
)
allow(Html2rss::Web::Flags).to receive(:auto_source_enabled?).and_return(true)

ClimateControl.modify(
'RACK_ENV' => 'production',
'HTML2RSS_SECRET_KEY' => '0123456789abcdef0123456789abcdef',
'BUILD_TAG' => '2026-03-27',
'GIT_SHA' => 'abc1234'
) do
expect { described_class.validate_production_security! }.to raise_error(SystemExit)
end

expect(Html2rss::Web::SecurityLogger).to have_received(:log_config_validation_failure)
.with('access_token', 'Placeholder create-feed token is not allowed when auto source is enabled')
end
end

describe '.auto_source_enabled?' do
Expand Down
25 changes: 25 additions & 0 deletions spec/html2rss/web/local_config_spec.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# frozen_string_literal: true

require 'spec_helper'
require 'climate_control'
require 'tempfile'

require_relative '../../../app/web/config/local_config'

Expand Down Expand Up @@ -68,5 +70,28 @@ def self.find_by_name(_name); end

expect(described_class.snapshot.accounts.first.username).to eq('alice')
end

it 'evaluates ERB before parsing YAML so env-backed tokens become effective config' do
Tempfile.create(['feeds', '.yml']) do |file|
file.write <<~YAML
auth:
accounts:
- username: admin
token: <%= Html2rss::Web::RuntimeEnv.access_token.to_s.strip.empty? ? 'CHANGE_ME_ADMIN_TOKEN' : Html2rss::Web::RuntimeEnv.access_token %>
allowed_urls:
- "*"
feeds: {}
YAML
file.flush

stub_const("#{described_class}::CONFIG_FILE", file.path)

ClimateControl.modify('HTML2RSS_ACCESS_TOKEN' => 'runtime-access-token') do
described_class.reload!

expect(described_class.snapshot.accounts.first.token).to eq('runtime-access-token')
end
end
end
end
end
Loading