Rails-Anwendung mit HTTP-/HTTPS-Kombination

Unser Seitenprojekt Hotelbewertung.de wurde umfassend neu aufgelegt. Eine Anforderung dabei war, bestimmte Seiten per SSL sicher zu übertragen, andere wiederum nicht. Was sich nach einem trivialen Problem anhört, hatte es dann doch ganz schön in sich, weil Rails vor allem beim Routing bzw. der Routengenerierung diesbezüglich nicht viel zu bieten hat. An diesem Punkt haben wir angesetzt und eine elegante Lösung entwickelt.

Einleitung

Bestimmte Seiten verlangen die Eingabe von persönlichen Daten und sollen deswegen selbstverständlich per SSL abgesichert sein. Andere Seiten wiederum binden Partnermaterial ein, das nicht per HTTPS verfügbar ist. Eine Protokollmischung auf einer Seite würde Browserwarnungen hervorrufen, deswegen müssen diese Seiten per HTTP statt HTTPS ausgeliefert werden.

Was ist das Problem?

Grundsätzlich ist es nicht schwierig, die Produktivwebserver so zu konfigurieren, dass sie HTTP- wie HTTPS-Anfragen beantworten und an die gleiche Rails-Anwendung weiterleiten. Jetzt folgt schon die erste Hürde: Im Routing muss definiert werden, welche Pfade mit und welche ohne SSL angefragt werden können. Das größere Problem ist die korrekte Routengenerierung, dass also das gewünschte Protokoll berücksichtigt wird, idealerweise sogar bei der Nutzung von _path-Helpern.

Vorbereitungen

Meine Ausführungen beziehen sich auf die gerade aktuelle Version von Rails, 3.2.11 und auf Sass-Rails in der Version 3.2.5. Neuere Versionen handhaben die Probleme vielleicht schon selbst, vielleicht funktionieren meine Monkey-Patches auch nicht mehr korrekt. Hier ist also Vorsicht geboten.

Zur lokalen Entwicklung mit den zwei Protokollen benötigt man auch zwei App-Server. Mit Passenger ließe sich gegebenenfalls ein Setup mit weniger Servern erstellen, hier arbeiten wir aber der Einfachheit halber mit Thin.

Unsere Entwicklungsumgebungen verwalten wir in der Regel mit tmuxinator, für ERB-Unterstützung wird allerdings unser fork benötigt.

project_name: ssl_test
project_root: ~/Tests/ssl_test
rvm: 1.9.3
tabs:
  - server:
    - bundle exec thin start --port 4001 -a <%= `hostname -f` %>
  - "ssl-server":
    - bundle exec thin start --ssl --port 4002 -a <%= `hostname -f` %>

Thin wird hier explizit an einen Hostnamen gebunden, sodass die App-Server nicht unter localhost oder ihrer IP erreichbar sind. Dies beugt Problemen mit der Same-Origin-Policy aller Web-Browser vor, die man bei ajax requests auf feste URLs dann bekäme.

1. Routingweiche

Welche Pfade können nur per HTTP oder nur per HTTPS angefragt werden? Dazu müssen wir natürlich in die routes.rb und entsprechende constraints setzen:

# routes.rb
SslTest::Application.routes.draw do
  scope constraints: { protocol: 'http://' } do
    match '/nonssl' => 'test#nonssl'
  end
  scope constraints: { protocol: 'https://' } do
    match '/ssl' => 'test#ssl'
  end
end

Weiterer Code zum Testen:

# test_controller.rb
class TestController < ApplicationController
  def nonssl
    render text: 'hello nonssl'
  end
  def ssl
    render text: 'hello ssl'
  end
end

Schnelle Tests mit dem Browser zeigen, dass der erste Pfad nur ohne, der zweite nur mit SSL erreichbar ist.

2. Routen werden falsch generiert

Erweitert man jetzt controller und views, dann stößt man auf das zweite Problem. Die Routen werden gegebenenfalls mit dem falschen Protokollpräfix generiert. Der folgende Beispielcode veranschaulicht die Problematik:

# test_controller.rb
class TestController < ApplicationController
  def nonssl
    render 'default'
  end
  def ssl
    render 'default'
  end
end
<!-- default.html.erb -->
<pre>
  <%= nonssl_path %> - <%= nonssl_url %>
  <%= ssl_path %> - <%= ssl_url %>
</pre>

Zwei Aufrufe liefern:

<!-- http://localhost:4001/nonssl -->
/nonssl - http://localhost:4001/nonssl
/ssl - http://localhost:4001/ssl
<!-- https://localhost:4002/ssl -->
/nonssl - https://localhost:4002/nonssl
/ssl - https://localhost:4002/ssl

Zwei Fehler zeigen sich: Das angegebene Protokoll der vollen URLs ist teilweise falsch, die Pfade sind teilweise nicht gültig.

2a. Protokoll in vollen URLs falsch

Dieser Fehler lässt sich relativ leicht korrigieren. Im routing muss das Protokoll als Parameter für die jeweiligen scopes gesetzt werden:

# routes.rb
SslTest::Application.routes.draw do
  scope protocol: 'http://', constraints: { protocol: 'http://' } do
    match '/nonssl' => 'test#nonssl'
  end

  scope protocol: 'https://', constraints: { protocol: 'https://' } do
    match '/ssl' => 'test#ssl'
  end
end
<!-- http://localhost:4001/nonssl -->
/nonssl - http://localhost:4001/nonssl
/ssl - https://localhost:4001/ssl
<!-- https://localhost:4002/ssl -->
/nonssl - http://localhost:4002/nonssl
/ssl - https://localhost:4002/ssl

Die Pfade sind noch falsch, die vollen URLs stimmen aber jetzt schon fast. Dass die Portangaben im Entwicklungsmodus ebenfalls noch falsch sind ist unschön, wird aber im nächsten Schritt mit gelöst.

2b. Ungültige Pfade

Rails generiert Pfade, die für das aktuelle Protokoll gegebenenfalls gar nicht gültig sind. Toll wäre, wenn in diesem Fall dann automatisch volle URLs statt Pfaden generiert werden würden. Ein Monkey-Patch zur Routengenerierung stellt das sicher:

# config/initializers/routing_fixes.rb

module ActionDispatch::Routing::UrlFor
  def url_for_with_protocol_switch(options = nil)
    if options.is_a?(Hash)

      if Rails.env.development?
        if options[:protocol] == 'https://'
          options[:port] = 4002
        else
          options[:port] = 4001
        end
      end

      # 2 Fälle wenn immer eine volle URL generiert werden muss:
      # * Wenn die Umgebung unklar ist, z. B. während der Generierung der
      #   assets
      # * Wenn der Link das Protokoll wechseln würde
      if !defined?(params) || params[:protocol] != options[:protocol]
        options[:only_path] = false
      end
    end
    url_for_without_protocol_switch(options)
  end
  alias_method_chain :url_for, :protocol_switch
end
<!-- http://localhost:4001/nonssl -->
/nonssl - http://localhost:4001/nonssl
https://localhost:4002/ssl - https://localhost:4002/ssl
<!-- https://localhost:4002/ssl -->
http://localhost:4001/nonssl - http://localhost:4001/nonssl
/ssl - https://localhost:4002/ssl

Voilà, richtige Portnummern und volle URLs, wenn Pfade falsch wären! Der Patch ist genial, hat aber leider noch ein paar Nachteile.

Capybara

Die _url-Helper funktionieren nicht mit Capybara, deswegen deaktivieren wir den Patch in der Test-Umgebung.

Sass-Rails

Man kann die Routen in Sprockets zur Verfügung stellen, z. B. so:

# config/initializers/sprockets.rb
clz = SslTest::Application.assets.context_class
clz.instance_eval do
  include Rails.application.routes.url_helpers
  cattr_reader :default_url_options
end
clz.class_variable_set '@@default_url_options',
                       { host: `hostname -f`.rstrip,
                         port: nil, protocol: nil }

Ein Problem tritt dann bei der Asset-Generierung auf, wenn man @import mit einer Pfadangabe verwendet und Root-Routen definiert hat. rake assets:precompile wirft dann den Fehler different prefix: "/" and "http:/www.ssltest.de"

Sass-Rails erwartet in Importer#resolve in diesem Fall nicht, dass #root_path eine volle URL liefert. Ich monkey-patche die Methode folgendermaßen:

# config/initializers/routing_fixes.rb
module Sass::Rails
  class Importer
    def resolve(name, base_pathname = nil)
      name = Pathname.new(name)
      if base_pathname && base_pathname.to_s.size > 0
        #root = Pathname.new(context.root_path)
        root = Pathname.new('/')

        name = base_pathname.relative_path_from(root).join(name)
      end
      partial_name = name.dirname.join("_#{name.basename}")
      @resolver.resolve(name) || @resolver.resolve(partial_name)
    end
  end
end

Zusammengefasst

Aus diesem langen Artikel folgt doch ein relativ übersichtlicher patch:

# config/initializers/routing_fixes.rb
unless Rails.env.test?
  module ActionDispatch::Routing::UrlFor
    def url_for_with_protocol_switch(options = nil)

      if options.is_a?(Hash)

        if Rails.env.development?
          if options[:protocol] == 'https://'
            options[:port] = 4002
          else
            options[:port] = 4001
          end
        end

        # 2 Fälle wenn immer eine volle URL generiert werden muss:
        # * Wenn die Umgebung unklar ist, z. B. während der Generierung der
        #   assets
        # * Wenn der Link das Protokoll wechseln würde
        if !defined?(params) || params[:protocol] != options[:protocol]
          options[:only_path] = false
        end
      end
      url_for_without_protocol_switch(options)
    end
    alias_method_chain :url_for, :protocol_switch
  end

end


# Während der Asset-Generierung liefert #root_path auch eine volle URL, was
# sass-rails aber nicht erwartet, deswegen monkey-patche ich hier die
# betreffende Methode.
module Sass::Rails
  class Importer
    def resolve(name, base_pathname = nil)
      name = Pathname.new(name)
      if base_pathname && base_pathname.to_s.size > 0
        #root = Pathname.new(context.root_path)
        root = Pathname.new('/')

        name = base_pathname.relative_path_from(root).join(name)
      end
      partial_name = name.dirname.join("_#{name.basename}")
      @resolver.resolve(name) || @resolver.resolve(partial_name)
    end
  end
end